- Optimized mobile image loading (180px vs 200px desktop) - Fixed Install App navigation not working on desktop - Fixed replaceChild null error in hero rendering - Added PWA icon (512x512) - Fixed back button navigation issues - Added mobile bottom padding for nav bar - Moved Get App FAB higher to avoid nav overlap - Removed unnecessary pushState from video navigation - Made Search/MyList tabs not scroll to top on mobile - Removed duplicate Android TV section from download page
1149 lines
45 KiB
JavaScript
1149 lines
45 KiB
JavaScript
/**
|
|
* 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. <p>, <br>)
|
|
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 += `<div><span class="text-white/50">Genres:</span> <span class="text-white">${genres.join(', ')}</span></div>`;
|
|
}
|
|
if (director && director !== 'Unknown') {
|
|
tagsHTML += `<div><span class="text-white/50">Director:</span> <span class="text-white">${director}</span></div>`;
|
|
}
|
|
if (country && country !== 'Unknown') {
|
|
tagsHTML += `<div><span class="text-white/50">Country:</span> <span class="text-white">${country}</span></div>`;
|
|
}
|
|
|
|
elements.movieTags.innerHTML = tagsHTML;
|
|
}
|
|
|
|
// Update page title
|
|
document.title = `${video.title} - StreamFlix`;
|
|
|
|
// Update Add to List button state
|
|
if (window.historyService && video.slug) {
|
|
updateAddListUI(window.historyService.isFavorite(video.slug));
|
|
}
|
|
|
|
// Render episodes
|
|
renderEpisodes(video);
|
|
|
|
// Render cast
|
|
if (video.tmdb_cast && video.tmdb_cast.length > 0) {
|
|
renderCast(video.tmdb_cast, true);
|
|
} else if (video.cast && video.cast.length > 0) {
|
|
renderCast(video.cast, false);
|
|
}
|
|
|
|
// Render additional details
|
|
renderDetails(video);
|
|
}
|
|
|
|
/**
|
|
* Render episodes grid (StreamFlix Tailwind Design)
|
|
*/
|
|
function renderEpisodes(video) {
|
|
if (!elements.episodesPanel) return;
|
|
|
|
// Get episodes from the API response format
|
|
let episodes = [];
|
|
if (Array.isArray(video.episodes) && video.episodes.length > 0) {
|
|
if (video.episodes[0].server_data) {
|
|
episodes = video.episodes[0].server_data;
|
|
} else {
|
|
episodes = video.episodes;
|
|
}
|
|
}
|
|
|
|
// Hide episodes section for single-episode movies
|
|
if (episodes.length <= 1) {
|
|
if (elements.seasonSelectContainer) elements.seasonSelectContainer.style.display = 'none';
|
|
if (elements.episodesLoading) elements.episodesLoading.style.display = 'none';
|
|
|
|
// Show "Play Movie" message instead
|
|
if (elements.episodesGrid) {
|
|
elements.episodesGrid.innerHTML = `
|
|
<div class="flex items-center gap-4 p-4 bg-surface-dark rounded-lg border border-white/5">
|
|
<span class="material-symbols-outlined text-3xl text-primary">play_circle</span>
|
|
<div>
|
|
<p class="text-white font-medium">Full Movie</p>
|
|
<p class="text-gray-400 text-sm">Click Play to watch</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Update episode count
|
|
if (elements.episodeCount) elements.episodeCount.textContent = `${episodes.length} Episodes`;
|
|
if (elements.episodesLoading) elements.episodesLoading.style.display = 'none';
|
|
|
|
// Render episode cards
|
|
if (elements.episodesGrid) {
|
|
const INITIAL_LIMIT = 10;
|
|
const totalEp = episodes.length;
|
|
const showAll = totalEp <= (INITIAL_LIMIT + 5); // If only a few more, just show all
|
|
|
|
const renderBatch = (limit) => {
|
|
elements.episodesGrid.innerHTML = episodes.slice(0, limit).map((ep, index) => {
|
|
const epNumber = index + 1;
|
|
const isActive = epNumber === state.currentEpisode;
|
|
const epName = ep.name || `Episode ${epNumber}`;
|
|
const epTitle = ep.title || ep.filename || '';
|
|
|
|
return `
|
|
<div class="flex items-start gap-4 p-4 rounded-lg cursor-pointer transition-colors hover:bg-surface-dark border border-transparent hover:border-white/10 ${isActive ? 'bg-surface-dark border-primary/40' : ''}" onclick="window.selectEpisode(${epNumber})">
|
|
<div class="flex-none text-2xl font-bold text-gray-500 w-8 text-center">${epNumber}</div>
|
|
<div class="flex-1">
|
|
<h4 class="text-white font-medium ${isActive ? 'text-primary' : ''}">${epName}</h4>
|
|
${epTitle ? `<p class="text-gray-400 text-sm mt-1 line-clamp-2">${epTitle}</p>` : ''}
|
|
</div>
|
|
${isActive ? '<span class="material-symbols-outlined text-primary">play_circle</span>' : ''}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
if (limit < totalEp) {
|
|
const seeMoreBtn = document.createElement('button');
|
|
seeMoreBtn.className = 'w-full py-4 text-gray-400 hover:text-white font-medium flex items-center justify-center gap-2 border-t border-white/5 mt-2 transition-colors';
|
|
seeMoreBtn.innerHTML = `
|
|
<span>See more episodes (${totalEp - limit} remaining)</span>
|
|
<span class="material-symbols-outlined">expand_more</span>
|
|
`;
|
|
seeMoreBtn.onclick = () => renderBatch(totalEp);
|
|
elements.episodesGrid.appendChild(seeMoreBtn);
|
|
}
|
|
};
|
|
|
|
renderBatch(showAll ? totalEp : INITIAL_LIMIT);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render additional details (About section)
|
|
*/
|
|
function renderDetails(video) {
|
|
if (!elements.detailsList) return;
|
|
|
|
const details = [];
|
|
|
|
if (video.original_title) details.push({ label: 'Original Title', value: video.original_title });
|
|
if (video.director && video.director !== 'Unknown') details.push({ label: 'Director', value: video.director });
|
|
if (video.country && video.country !== 'Unknown') details.push({ label: 'Country', value: video.country });
|
|
if (video.year) details.push({ label: 'Release Year', value: video.year });
|
|
if (video.quality) details.push({ label: 'Quality', value: video.quality });
|
|
if (video.duration) details.push({ label: 'Duration', value: video.duration });
|
|
if (video.genres && video.genres.length > 0) details.push({ label: 'Genres', value: video.genres.join(', ') });
|
|
|
|
// Clear existing
|
|
elements.detailsList.innerHTML = '';
|
|
|
|
details.forEach(d => {
|
|
const row = document.createElement('div');
|
|
row.className = 'flex gap-4';
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'text-white/50 min-w-[100px] font-medium';
|
|
label.textContent = `${d.label}:`;
|
|
|
|
const value = document.createElement('span');
|
|
value.className = 'text-white font-medium';
|
|
value.textContent = d.value;
|
|
|
|
row.appendChild(label);
|
|
row.appendChild(value);
|
|
elements.detailsList.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// Global scope for onclick
|
|
window.selectEpisode = (episodeNumber) => {
|
|
state.currentEpisode = episodeNumber;
|
|
|
|
// Update URL
|
|
const url = new URL(window.location);
|
|
url.searchParams.set('ep', episodeNumber);
|
|
window.history.replaceState({}, '', url);
|
|
|
|
// Re-render to update active state
|
|
renderEpisodes(state.video);
|
|
|
|
// Play
|
|
playCurrentEpisode();
|
|
|
|
// Scroll to top
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
};
|
|
|
|
/**
|
|
* Play current episode
|
|
*/
|
|
async function playCurrentEpisode() {
|
|
if (!state.video) return;
|
|
|
|
if (elements.playerLoading) elements.playerLoading.style.display = 'flex';
|
|
|
|
try {
|
|
let streamUrl = null;
|
|
let poster = state.video.thumbnail;
|
|
|
|
// Get episodes from the API response format (ophim format has server_data)
|
|
let episodes = [];
|
|
if (Array.isArray(state.video.episodes) && state.video.episodes.length > 0) {
|
|
if (state.video.episodes[0].server_data) {
|
|
episodes = state.video.episodes[0].server_data;
|
|
} else {
|
|
episodes = state.video.episodes;
|
|
}
|
|
}
|
|
|
|
const currentEp = episodes[state.currentEpisode - 1];
|
|
|
|
// Save to history
|
|
if (window.historyService) {
|
|
window.historyService.addToHistory(state.video, {
|
|
episode: state.currentEpisode,
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
|
|
// Try to get stream URL from episode data (ophim provides direct links)
|
|
if (currentEp) {
|
|
// Prefer m3u8 for native playback, fallback to embed
|
|
if (currentEp.link_m3u8) {
|
|
streamUrl = currentEp.link_m3u8;
|
|
} else if (currentEp.link_embed) {
|
|
streamUrl = currentEp.link_embed;
|
|
}
|
|
}
|
|
|
|
// If still no stream, try getting it via the getRophimStream method
|
|
if (!streamUrl && state.video.slug) {
|
|
try {
|
|
const streamData = await api.getRophimStream(state.video.slug, state.currentEpisode);
|
|
if (streamData?.stream_url) streamUrl = streamData.stream_url;
|
|
} catch (e) {
|
|
console.warn('Stream API fallback also failed', e);
|
|
}
|
|
}
|
|
|
|
if (elements.playerLoading) elements.playerLoading.style.display = 'none';
|
|
|
|
if (streamUrl) {
|
|
renderPlayer(streamUrl, poster, state.video.title);
|
|
const epLabel = episodes.length > 1 ? `Episode ${state.currentEpisode} ` : 'Movie';
|
|
showToast(`Playing ${epLabel} `, 'success');
|
|
} else {
|
|
// Show watch externally option
|
|
const episodeStr = state.currentEpisode === 1 ? 'full' : state.currentEpisode;
|
|
const externalUrl = `https://phimmoichill.network/xem-phim/${state.video.slug}/tap-${episodeStr}-sv-0`;
|
|
showExternalPlayerOption(externalUrl);
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
showPlaybackError(error.message);
|
|
}
|
|
}
|
|
|
|
function showExternalPlayerOption(externalUrl) {
|
|
elements.videoPlayer.innerHTML = `
|
|
<div style="display:flex;height:100%;align-items:center;justify-content:center;flex-direction:column;gap:20px;padding:40px;text-align:center;">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="64" height="64" style="color:#ff4d4d; opacity: 0.8;">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<line x1="15" y1="9" x2="9" y2="15"/>
|
|
<line x1="9" y1="9" x2="15" y2="15"/>
|
|
</svg>
|
|
<h3 style="color:#fff;margin:0;font-size:1.5rem;">It cannot load</h3>
|
|
<p style="color:#aaa;margin:0;max-width:400px;opacity:0.7;">This stream is currently unavailable. Please try again later or choose another source.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Render player
|
|
*/
|
|
function renderPlayer(streamUrl, poster, title) {
|
|
// Check if embed (add simple check for common embed domains)
|
|
const isEmbed = streamUrl.includes('embed') || !streamUrl.match(/\.(mp4|m3u8)$/i);
|
|
|
|
if (isEmbed) {
|
|
elements.videoPlayer.innerHTML = `
|
|
<iframe src="${streamUrl}" allowfullscreen allow="autoplay; encrypted-media"></iframe>
|
|
`;
|
|
} else {
|
|
// Initialize ArtPlayer
|
|
const art = renderArtPlayer(streamUrl, poster, title);
|
|
|
|
// Push state to history for back navigation
|
|
if (!window.history.state?.playerOpen) {
|
|
window.history.pushState({ playerOpen: true }, '', window.location.href);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render ArtPlayer instance
|
|
*/
|
|
function renderArtPlayer(streamUrl, poster, title) {
|
|
const art = initPlayer(elements.videoPlayer, {
|
|
url: streamUrl,
|
|
poster: poster,
|
|
title: title + ` - Ep ${state.currentEpisode}`,
|
|
autoplay: true
|
|
});
|
|
|
|
// Track progress
|
|
if (art && window.historyService) {
|
|
art.on('video:timeupdate', () => {
|
|
const currentTime = art.currentTime;
|
|
const duration = art.duration;
|
|
if (currentTime > 0 && duration > 0) {
|
|
// Save every 5 seconds to avoid excessive writes
|
|
if (Math.floor(currentTime) % 5 === 0) {
|
|
window.historyService.addToHistory(state.video, {
|
|
currentTime,
|
|
duration,
|
|
percentage: (currentTime / duration) * 100,
|
|
episode: state.currentEpisode
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Resume from last position if available
|
|
const history = window.historyService.getHistory();
|
|
const entry = history.find(item => item.slug === state.video.slug);
|
|
if (entry && entry.progress && entry.progress.episode === state.currentEpisode) {
|
|
if (entry.progress.currentTime > 0 && entry.progress.percentage < 95) {
|
|
art.once('video:canplay', () => {
|
|
art.currentTime = entry.progress.currentTime;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function showPlaybackError(msg) {
|
|
elements.videoPlayer.innerHTML = `
|
|
<div class="video-theater__loading">
|
|
<p>Error loading video: ${msg}</p>
|
|
<button class="action-btn action-btn--primary" onclick="location.reload()">Retry</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Render Cast (StreamFlix Tailwind Design - circular avatars)
|
|
*/
|
|
function renderCast(cast, isTMDB = false) {
|
|
if (!elements.castCarousel) return;
|
|
|
|
const displayCast = cast.slice(0, 10);
|
|
|
|
if (isTMDB) {
|
|
elements.castCarousel.innerHTML = displayCast.map(person => {
|
|
const hasPhoto = person.profile_photo && !person.profile_photo.includes('ui-avatars.com');
|
|
const photoUrl = person.profile_photo || '';
|
|
const searchUrl = `/?search=${encodeURIComponent(person.name)}`;
|
|
const initials = person.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
|
|
|
return `
|
|
<a href="${searchUrl}" class="flex-none w-28 group text-center snap-start">
|
|
<div class="size-20 mx-auto rounded-full overflow-hidden bg-surface-dark border-2 border-transparent group-hover:border-primary transition-all">
|
|
${hasPhoto
|
|
? `<img src="${photoUrl}" alt="${person.name}" class="w-full h-full object-cover" loading="lazy">`
|
|
: `<div class="w-full h-full flex items-center justify-center text-2xl font-bold text-gray-400">${initials}</div>`
|
|
}
|
|
</div>
|
|
<p class="text-white text-sm font-medium mt-2 truncate group-hover:text-primary transition-colors">${person.name}</p>
|
|
<p class="text-gray-400 text-xs truncate">${person.character || 'Actor'}</p>
|
|
</a>
|
|
`;
|
|
}).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 `
|
|
<a href="${searchUrl}" class="flex-none w-28 group text-center snap-start">
|
|
<div class="size-20 mx-auto rounded-full overflow-hidden bg-surface-dark border-2 border-transparent group-hover:border-primary transition-all flex items-center justify-center">
|
|
<span class="text-2xl font-bold text-gray-400">${initials}</span>
|
|
</div>
|
|
<p class="text-white text-sm font-medium mt-2 truncate group-hover:text-primary transition-colors">${actor}</p>
|
|
<p class="text-gray-400 text-xs truncate">Actor</p>
|
|
</a>
|
|
`;
|
|
}).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 = '<div class="flex justify-center py-12"><div class="loading-spinner"></div></div>';
|
|
|
|
const video = state.video;
|
|
if (!video) return;
|
|
|
|
const currentSlug = video.slug;
|
|
const usedSlugs = new Set([currentSlug]);
|
|
|
|
// 1. Prepare Categories
|
|
const genres = video.category ? Object.values(video.category) : (video.genres || []);
|
|
const countries = video.country ? Object.values(video.country) : (video.countries || []);
|
|
const year = video.year;
|
|
|
|
const requests = [];
|
|
|
|
// Category 1: Similar (Genre)
|
|
if (genres.length > 0) {
|
|
let genreSlug = '';
|
|
// Handle both object {id: name} and string array
|
|
if (typeof genres[0] === 'object' && genres[0].slug) {
|
|
genreSlug = genres[0].slug;
|
|
} else if (typeof genres[0] === 'string') {
|
|
genreSlug = genres[0].toLowerCase()
|
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/đ/g, 'd').replace(/\s+/g, '-');
|
|
}
|
|
|
|
// Adjust slug logic if needed based on API
|
|
// For RoPhim it's often 'the-loai/<slug>'
|
|
if (genreSlug) {
|
|
requests.push(
|
|
api.getRophimCatalog({ page: 1, limit: 24, category: `the-loai/${genreSlug}` })
|
|
.then(res => ({ title: "More Like This", movies: res.movies || [] }))
|
|
.catch(() => null)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Category 2: Same Country
|
|
if (countries.length > 0) {
|
|
let countrySlug = '';
|
|
if (typeof countries[0] === 'object' && countries[0].slug) {
|
|
countrySlug = countries[0].slug;
|
|
} else if (typeof countries[0] === 'string') {
|
|
countrySlug = countries[0].toLowerCase()
|
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/đ/g, 'd').replace(/\s+/g, '-');
|
|
}
|
|
|
|
if (countrySlug) {
|
|
requests.push(
|
|
api.getRophimCatalog({ page: 1, limit: 24, category: `quoc-gia/${countrySlug}` })
|
|
.then(res => ({ title: `Movies from ${countries[0].name || countries[0]}`, movies: res.movies || [] }))
|
|
.catch(() => null)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Category 3: Same Year
|
|
if (year) {
|
|
requests.push(
|
|
api.getRophimCatalog({ page: 1, limit: 24, category: `nam-phat-hanh/${year}` })
|
|
.then(res => ({ title: `Released in ${year}`, movies: res.movies || [] }))
|
|
.catch(() => null)
|
|
);
|
|
}
|
|
|
|
// Execute all requests
|
|
const results = await Promise.all(requests);
|
|
|
|
container.innerHTML = ''; // Clear loading
|
|
|
|
const renderedTitles = new Set();
|
|
let hasContent = false;
|
|
|
|
results.forEach(section => {
|
|
if (!section || !section.movies || section.movies.length === 0) return;
|
|
|
|
// Deduplicate Titles (Prevent multiple 'More Like This')
|
|
if (section.title && renderedTitles.has(section.title)) return;
|
|
if (section.title) renderedTitles.add(section.title);
|
|
|
|
// Filter duplicates
|
|
const uniqueMovies = section.movies.filter(m => !usedSlugs.has(m.slug));
|
|
uniqueMovies.forEach(m => usedSlugs.add(m.slug));
|
|
|
|
if (uniqueMovies.length === 0) return;
|
|
|
|
hasContent = true;
|
|
|
|
const sectionHtml = `
|
|
<div class="space-y-4">
|
|
${section.title ? `<h4 class="text-md font-bold text-gray-300 pl-4 md:pl-0 border-l-2 border-primary md:border-none md:pl-0 leading-none h-5 flex items-center md:block">${section.title}</h4>` : ''}
|
|
<div class="grid grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-3">
|
|
${uniqueMovies.map(v => createCardHtml(v)).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
container.insertAdjacentHTML('beforeend', sectionHtml);
|
|
});
|
|
|
|
if (!hasContent) {
|
|
container.innerHTML = '<p class="text-gray-400 text-center py-8">No specific recommendations found.</p>';
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load recommendations:', error);
|
|
container.innerHTML = '<p class="text-gray-400 text-center py-8">Failed to load recommendations</p>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 `
|
|
<div class="flex-none w-full cursor-pointer group relative transition-all duration-300 hover:z-30 hover:scale-105" onclick="window.location.href='/watch.html?id=${slug}'">
|
|
<div class="relative aspect-[2/3] rounded-md overflow-hidden bg-surface-dark shadow-sm group-hover:shadow-lg 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('${poster}');"></div>
|
|
|
|
<!-- Gradient Overlay -->
|
|
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
|
|
|
<!-- Badges -->
|
|
<div class="absolute top-2 left-2 flex flex-col gap-1 z-20">
|
|
${year == new Date().getFullYear() ? `<span class="bg-primary text-white text-[8px] font-bold px-1.5 py-0.5 rounded shadow">NEW</span>` : ''}
|
|
<span class="bg-black/60 backdrop-blur-md text-white text-[8px] font-bold px-1.5 py-0.5 rounded border border-white/10 uppercase">${quality.replace('FHD', 'HD')}</span>
|
|
</div>
|
|
|
|
<!-- Content Overlay -->
|
|
<div class="absolute inset-0 z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-2 pointer-events-none">
|
|
<div class="flex items-center gap-1 mb-1 pointer-events-auto">
|
|
<button class="bg-white text-black size-6 rounded-full flex items-center justify-center hover:scale-110 transition-transform">
|
|
<span class="material-symbols-outlined text-[16px]">play_arrow</span>
|
|
</button>
|
|
<button class="bg-black/60 border border-gray-400 text-white size-6 rounded-full flex items-center justify-center hover:bg-zinc-700 transition-transform">
|
|
<span class="material-symbols-outlined text-[14px]">add</span>
|
|
</button>
|
|
</div>
|
|
|
|
<h3 class="text-xs font-bold text-white line-clamp-1 mb-0.5 drop-shadow-md">${title}</h3>
|
|
<div class="flex items-center gap-1.5 text-[9px] text-gray-300 font-medium">
|
|
<span class="text-[#46d369] font-bold">${match}% Match</span>
|
|
<div class="flex items-center gap-1">
|
|
<span class="bg-[#FA320A] text-white px-1 rounded flex items-center gap-0.5 h-3">
|
|
<span class="material-symbols-outlined text-[8px]">local_pizza</span> ${rtScore}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
|
|
|
|
function showError(msg) {
|
|
document.body.innerHTML = `
|
|
<div class="min-h-screen flex flex-col items-center justify-center text-white gap-6 p-4">
|
|
<span class="material-symbols-outlined text-6xl text-primary">error</span>
|
|
<h1 class="text-2xl font-bold">${msg}</h1>
|
|
<a href="/" class="bg-primary text-white px-6 py-2 rounded font-medium hover:bg-primary/90 transition-colors">Go Home</a>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Init
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|