document.addEventListener('DOMContentLoaded', () => { // DOM Elements const videoGrid = document.getElementById('videoGrid'); const searchInput = document.getElementById('searchInput'); const clearSearchBtn = document.getElementById('clearSearch'); const videoModal = document.getElementById('videoModal'); const videoPlayer = document.getElementById('videoPlayer'); const modalVideoTitle = document.getElementById('modalVideoTitle'); const closeModal = document.getElementById('closeModal'); const loadingIndicator = document.getElementById('loadingIndicator'); const errorMessage = document.getElementById('errorMessage'); const retryBtn = document.getElementById('retryBtn'); const gridViewBtn = document.getElementById('gridViewBtn'); const listViewBtn = document.getElementById('listViewBtn'); const pagination = document.getElementById('pagination'); const visibleCount = document.getElementById('visibleCount'); const totalCount = document.getElementById('totalCount'); // *** NEW: Player Loading Elements *** const playerLoadingIndicator = document.getElementById('playerLoadingIndicator'); const playerLoadPercent = document.getElementById('playerLoadPercent'); // State let videos = []; let filteredVideos = []; let currentPage = 1; let videosPerPage = 12; let currentPlayer = null; let viewMode = 'grid'; let lastTapTime = 0; let lastDoubleTapX = 0; let doubleTapThreshold = 10; // px tolerance for tap position // Initialize the app init(); function init() { fetchVideoData(); setupEventListeners(); } // Fetch video data from JSON async function fetchVideoData() { try { loadingIndicator.style.display = 'flex'; errorMessage.style.display = 'none'; videoGrid.innerHTML = ''; const response = await fetch('database.json'); if (!response.ok) { throw new Error(`Failed to load database.json: ${response.status}`); } const data = await response.json(); // Check if data has the expected structure if (!data.videos || !Array.isArray(data.videos)) { throw new Error('Invalid database format: missing "videos" array'); } // Filter out invalid entries videos = data.videos.filter(video => { if (!video.title || !video.url) { console.warn('Invalid video entry:', video); return false; } return true; }); filteredVideos = [...videos]; updateVideoCount(); renderPagination(); renderVideos(); showToast('Videos loaded successfully!', 'success'); } catch (error) { console.error('Error loading videos:', error); loadingIndicator.style.display = 'none'; errorMessage.style.display = 'flex'; } } // Set up event listeners function setupEventListeners() { // Search functionality searchInput.addEventListener('input', handleSearch); clearSearchBtn.addEventListener('click', clearSearch); // Modal controls closeModal.addEventListener('click', closeVideoModal); // Retry button retryBtn.addEventListener('click', fetchVideoData); // View toggle gridViewBtn.addEventListener('click', () => setViewMode('grid')); listViewBtn.addEventListener('click', () => setViewMode('list')); // Handle clicks outside the modal to close it window.addEventListener('click', (e) => { if (e.target === videoModal) { closeVideoModal(); } }); // Keyboard controls for modal window.addEventListener('keydown', (e) => { if (videoModal.style.display === 'block') { if (e.key === 'Escape') { closeVideoModal(); } } }); } // Handle search input function handleSearch() { const searchTerm = searchInput.value.toLowerCase().trim(); clearSearchBtn.style.display = searchTerm ? 'block' : 'none'; if (searchTerm === '') { filteredVideos = [...videos]; } else { filteredVideos = videos.filter(video => video.title.toLowerCase().includes(searchTerm) ); } currentPage = 1; updateVideoCount(); renderPagination(); renderVideos(); } // Clear search function clearSearch() { searchInput.value = ''; clearSearchBtn.style.display = 'none'; filteredVideos = [...videos]; currentPage = 1; updateVideoCount(); renderPagination(); renderVideos(); } // Update video count display function updateVideoCount() { const start = (currentPage - 1) * videosPerPage; const end = Math.min(start + videosPerPage, filteredVideos.length); const count = Math.min(videosPerPage, filteredVideos.length - start); visibleCount.textContent = count > 0 ? end - start : 0; totalCount.textContent = filteredVideos.length; } // Set view mode (grid or list) function setViewMode(mode) { viewMode = mode; // Update active button gridViewBtn.classList.toggle('active', mode === 'grid'); listViewBtn.classList.toggle('active', mode === 'list'); // Update grid class videoGrid.classList.toggle('list-view', mode === 'list'); // Re-render to apply changes renderVideos(); } // Render videos in grid function renderVideos() { loadingIndicator.style.display = 'none'; videoGrid.innerHTML = ''; if (filteredVideos.length === 0) { videoGrid.innerHTML = `

No videos found matching your search.

`; updateVideoCount(); // Ensure count shows 0 of 0 return; } // Calculate pagination const start = (currentPage - 1) * videosPerPage; const end = Math.min(start + videosPerPage, filteredVideos.length); const currentPageVideos = filteredVideos.slice(start, end); updateVideoCount(); // Update count based on current page // Create video cards currentPageVideos.forEach((video, index) => { const videoCard = document.createElement('div'); videoCard.className = `video-card ${viewMode === 'list' ? 'list-view' : ''}`; // Determine if URL is from Hugging Face const isHuggingFaceUrl = video.url.includes('huggingface.co'); // For HF URLs, append download=true if not already present let videoUrl = video.url; if (isHuggingFaceUrl && !videoUrl.includes('download=true')) { videoUrl = videoUrl.includes('?') ? `${videoUrl}&download=true` : `${videoUrl}?download=true`; } videoCard.innerHTML = `

${video.title}

`; videoCard.addEventListener('click', () => { playVideo(video); }); videoGrid.appendChild(videoCard); }); } // Render pagination controls function renderPagination() { pagination.innerHTML = ''; if (filteredVideos.length <= videosPerPage) { return; // No pagination needed } const totalPages = Math.ceil(filteredVideos.length / videosPerPage); // Previous button const prevBtn = document.createElement('button'); prevBtn.className = `page-btn ${currentPage === 1 ? 'disabled' : ''}`; prevBtn.innerHTML = ''; prevBtn.disabled = currentPage === 1; prevBtn.addEventListener('click', () => { if (currentPage > 1) { goToPage(currentPage - 1); } }); pagination.appendChild(prevBtn); // Page numbers with ellipsis const renderPageButton = (pageNum) => { const pageBtn = document.createElement('button'); pageBtn.className = `page-btn ${currentPage === pageNum ? 'active' : ''}`; pageBtn.textContent = pageNum; pageBtn.addEventListener('click', () => goToPage(pageNum)); pagination.appendChild(pageBtn); }; // Determine visible page range const delta = 1; // Reduced delta for smaller screens / cleaner look const range = []; const rangeWithDots = []; let l; range.push(1); // Always show first page // Calculate boundaries for center range let left = currentPage - delta; let right = currentPage + delta; if (left <= 1) left = 2; if (right >= totalPages) right = totalPages - 1; // Add pages around current page for (let i = left; i <= right; i++) { if (i > 1 && i < totalPages) { // Avoid duplicates if delta overlaps 1 or totalPages range.push(i); } } if (totalPages > 1) { // Always show last page if different from first range.push(totalPages); } range.sort((a,b) => a - b); // Ensure order // Add ellipsis logic for (let i of range) { if (l) { if (i - l === 2) { rangeWithDots.push(l + 1); // Add missing page } else if (i - l > 1) { rangeWithDots.push('...'); // Add ellipsis } } rangeWithDots.push(i); l = i; } // Render the determined buttons/ellipsis rangeWithDots.forEach(item => { if (item === '...') { const ellipsis = document.createElement('span'); ellipsis.className = 'page-btn disabled'; ellipsis.textContent = '...'; pagination.appendChild(ellipsis); } else { renderPageButton(item); } }); // Next button const nextBtn = document.createElement('button'); nextBtn.className = `page-btn ${currentPage === totalPages ? 'disabled' : ''}`; nextBtn.innerHTML = ''; nextBtn.disabled = currentPage === totalPages; nextBtn.addEventListener('click', () => { if (currentPage < totalPages) { goToPage(currentPage + 1); } }); pagination.appendChild(nextBtn); } // Navigate to a specific page function goToPage(pageNum) { currentPage = pageNum; renderPagination(); // Re-render pagination to update active state/ellipsis renderVideos(); // Re-render videos for the new page updateVideoCount(); // Update counts *after* rendering new page // Scroll to top of video grid // Use a slight delay if needed for smooth scroll to work reliably after render setTimeout(() => { const controlsBar = document.querySelector('.controls-bar'); if (controlsBar) { controlsBar.scrollIntoView({ behavior: 'smooth', block: 'start' }); } else { videoGrid.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }, 50); } // Play video in modal function playVideo(video) { // Clean up any existing player *before* setting up the new one cleanupPlayer(); console.log("Setting up new video:", video.title); // Update modal title modalVideoTitle.textContent = video.title; // Determine if URL is from Hugging Face const isHuggingFaceUrl = video.url.includes('huggingface.co'); // For HF URLs, append download=true if not already present let videoUrl = video.url; if (isHuggingFaceUrl && !videoUrl.includes('download=true')) { videoUrl = videoUrl.includes('?') ? `${videoUrl}&download=true` : `${videoUrl}?download=true`; } // Set up video player source videoPlayer.src = videoUrl; // Initialize Plyr try { currentPlayer = new Plyr(videoPlayer, { controls: [ 'play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen' ], keyboard: { focused: true, global: false }, tooltips: { controls: true, seek: true }, captions: { active: true, language: 'auto', update: true } }); console.log("Plyr initialized."); } catch (error) { console.error("Error initializing Plyr:", error); showToast('Failed to initialize video player.', 'error'); closeVideoModal(); // Close modal if Plyr fails return; } // --- Buffering Event Listeners --- const handleVideoWaiting = () => { console.log("Video waiting (buffering)..."); playerLoadPercent.textContent = 'Buffering...'; // Update text during wait playerLoadingIndicator.style.display = 'flex'; }; const handleVideoPlaying = () => { console.log("Video playing."); playerLoadingIndicator.style.display = 'none'; }; const handleVideoProgress = () => { if (!videoPlayer.duration || !isFinite(videoPlayer.duration)) { playerLoadPercent.textContent = 'Loading...'; // Or 'Live' if applicable return; // Avoid NaN if duration isn't loaded or is infinite } try { let bufferedEnd = 0; if (videoPlayer.buffered.length > 0) { // Get the end time of the last buffered time range bufferedEnd = videoPlayer.buffered.end(videoPlayer.buffered.length - 1); } const loadPercent = Math.round((bufferedEnd / videoPlayer.duration) * 100); // Only update if indicator is visible (or always, depending on preference) // if (playerLoadingIndicator.style.display === 'flex') { playerLoadPercent.textContent = `Loading ${loadPercent}%`; // } // console.log(`Buffered: ${bufferedEnd.toFixed(2)}s / ${videoPlayer.duration.toFixed(2)}s (${loadPercent}%)`); } catch (e) { console.warn("Error calculating buffer progress:", e); playerLoadPercent.textContent = 'Loading...'; } }; // Store handlers on the element for easy removal later videoPlayer._eventListeners = { waiting: handleVideoWaiting, playing: handleVideoPlaying, progress: handleVideoProgress }; videoPlayer.addEventListener('waiting', videoPlayer._eventListeners.waiting); videoPlayer.addEventListener('playing', videoPlayer._eventListeners.playing); videoPlayer.addEventListener('progress', videoPlayer._eventListeners.progress); // Also hide indicator initially if player emits 'canplay' or ready early on // Use Plyr's 'ready' event as it's more reliable after initialization if (currentPlayer) { currentPlayer.once('ready', () => { console.log("Plyr ready event fired."); // Duration might be available here, trigger initial progress update handleVideoProgress(); // If it's not already playing and not buffering, hide loader if (!videoPlayer.paused && videoPlayer.readyState >= 3) { // HAVE_FUTURE_DATA or more playerLoadingIndicator.style.display = 'none'; } }); // Handle cases where video fails to load within Plyr currentPlayer.once('error', (event) => { console.error("Plyr error event:", event.detail.plyr.source); showToast('Error loading video source.', 'error'); playerLoadingIndicator.style.display = 'none'; // Hide indicator on error }); } // Reset loading text for the new video playerLoadPercent.textContent = 'Loading 0%'; // Set up double-tap controls for mobile setupDoubleTapControls(); // Show modal videoModal.style.display = 'block'; document.body.style.overflow = 'hidden'; // Prevent scrolling while modal is open // Attempt to Play the video (with error handling) try { // Show loading indicator immediately before play attempt playerLoadingIndicator.style.display = 'flex'; playerLoadPercent.textContent = 'Loading 0%'; // Reset text const playPromise = currentPlayer.play(); if (playPromise !== undefined) { playPromise.then(_ => { console.log("Autoplay successful or initiated."); // 'playing' event will hide the indicator }).catch(error => { console.warn('Autoplay prevented:', error); // Autoplay was prevented. User needs to click play. // The indicator is already shown. 'playing' event will hide it when user clicks. showToast('Click play to start video', 'info'); // Ensure indicator hides if playing starts manually after prevention currentPlayer.once('playing', handleVideoPlaying); }); } } catch (error) { console.error('Error initiating video play:', error); showToast('Error playing video. Please try again.', 'error'); playerLoadingIndicator.style.display = 'none'; // Hide indicator on critical error } } // Set up double-tap controls for mobile function setupDoubleTapControls() { const playerElement = videoPlayer.closest('.plyr'); // Target the Plyr container if (!playerElement) { console.warn("Could not find Plyr container for double-tap setup."); return; } const handleTap = (e) => { if (!currentPlayer) return; // Don't do anything if player isn't active const now = Date.now(); // Use clientX from the touch event const tapX = e.touches && e.touches.length > 0 ? e.touches[0].clientX : e.clientX; // Get bounding box relative to viewport const rect = playerElement.getBoundingClientRect(); const playerWidth = rect.width; const tapPositionRelative = (tapX - rect.left) / playerWidth; // 0 to 1 position within player bounds // Check tap time and proximity if (now - lastTapTime < 300 && Math.abs(tapX - lastDoubleTapX) < doubleTapThreshold * (window.devicePixelRatio || 1)) { // Double tap detected e.preventDefault(); // Prevent zoom or other default actions // Ensure feedback is relative to the correct container const feedbackContainer = playerElement.querySelector('.plyr__video-wrapper') || playerElement; if (tapPositionRelative < 0.33) { // Use thirds for clearer zones // Double tap on left side - rewind currentPlayer.rewind(10); showControlFeedback(feedbackContainer, 'left', 'Rewind 10s'); } else if (tapPositionRelative > 0.66) { // Double tap on right side - forward currentPlayer.forward(10); showControlFeedback(feedbackContainer, 'right', 'Forward 10s'); } else { // Double tap in center - play/pause if (currentPlayer.playing) { currentPlayer.pause(); } else { currentPlayer.play(); } // Optional: show play/pause feedback // showControlFeedback(feedbackContainer, 'center', currentPlayer.playing ? 'Pause' : 'Play'); } lastTapTime = 0; // Reset time after double tap to prevent triple tap issues lastDoubleTapX = 0; } else { // Single tap lastTapTime = now; lastDoubleTapX = tapX; } }; // Add touch event listener - use touchstart for mobile responsiveness playerElement.addEventListener('touchstart', handleTap, { passive: false }); // Need passive: false to call preventDefault // Store for cleanup playerElement.handleTapFunc = handleTap; } // Visual feedback for touch controls function showControlFeedback(container, position, text) { // Remove any existing feedback first const existingFeedback = container.querySelector('.tap-feedback'); if(existingFeedback) existingFeedback.remove(); // Create a feedback element const feedback = document.createElement('div'); feedback.className = `tap-feedback ${position}`; feedback.innerHTML = `
${text}
`; // Basic inline styles (better to define in CSS) feedback.style.position = 'absolute'; feedback.style.top = '50%'; feedback.style.transform = 'translateY(-50%) scale(0.8)'; // Start smaller feedback.style.color = 'white'; feedback.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'; feedback.style.padding = '10px 15px'; feedback.style.borderRadius = '8px'; // Less prominent than circle feedback.style.display = 'flex'; feedback.style.flexDirection = 'column'; feedback.style.alignItems = 'center'; feedback.style.zIndex = '20'; // Ensure visibility over video/some controls feedback.style.opacity = '0'; feedback.style.transition = 'opacity 0.5s ease-in-out, transform 0.5s ease-in-out'; feedback.style.pointerEvents = 'none'; // Don't interfere with clicks if (position === 'left') { feedback.style.left = '15%'; feedback.style.transform = 'translate(-50%, -50%) scale(0.8)'; } else if (position === 'right') { feedback.style.right = '15%'; feedback.style.transform = 'translate(50%, -50%) scale(0.8)'; // Correct transform for right } else { // Center (Play/Pause) feedback.style.left = '50%'; feedback.style.transform = 'translate(-50%, -50%) scale(0.8)'; } // Add to the specified container (e.g., plyr__video-wrapper) container.appendChild(feedback); // Animate in, then out requestAnimationFrame(() => { feedback.style.opacity = '1'; feedback.style.transform = feedback.style.transform.replace('scale(0.8)', 'scale(1)'); // Grow to full size setTimeout(() => { feedback.style.opacity = '0'; feedback.style.transform = feedback.style.transform.replace('scale(1)', 'scale(0.8)'); // Shrink out setTimeout(() => { feedback.remove(); }, 500); // Match transition duration }, 600); // Duration visible }); } // Close video modal and clean up player function closeVideoModal() { // Clean up player first cleanupPlayer(); // Hide modal videoModal.style.display = 'none'; document.body.style.overflow = ''; // Restore scrolling } // *** UPDATED: Clean up video player *** function cleanupPlayer() { console.log("Cleaning up player..."); const playerElement = videoPlayer.closest('.plyr'); // Get Plyr wrapper // Remove buffering event listeners first if (videoPlayer._eventListeners) { console.log("Removing video event listeners..."); videoPlayer.removeEventListener('waiting', videoPlayer._eventListeners.waiting); videoPlayer.removeEventListener('playing', videoPlayer._eventListeners.playing); videoPlayer.removeEventListener('progress', videoPlayer._eventListeners.progress); delete videoPlayer._eventListeners; // Clear the stored listeners // Hide player loading indicator just in case if (playerLoadingIndicator) { playerLoadingIndicator.style.display = 'none'; } } // Remove touch event listeners from the player container if (playerElement && playerElement.handleTapFunc) { console.log("Removing touch listener..."); playerElement.removeEventListener('touchstart', playerElement.handleTapFunc); delete playerElement.handleTapFunc; } // Destroy Plyr instance if it exists if (currentPlayer) { try { console.log("Destroying Plyr instance..."); // Pause Plyr first (best practice) currentPlayer.pause(); // Destroy Plyr instance currentPlayer.destroy(); console.log("Plyr instance destroyed."); } catch (e) { console.warn('Error destroying Plyr instance:', e); } finally { currentPlayer = null; // Ensure reference is cleared even if destroy fails } } // Reset the native video element thoroughly console.log("Resetting native video element..."); videoPlayer.pause(); // Ensure native element is paused videoPlayer.removeAttribute('src'); // Remove the source attribute // Clear any child elements if they were added dynamically while (videoPlayer.firstChild) { videoPlayer.removeChild(videoPlayer.firstChild); } videoPlayer.src = ''; // Explicitly set src to empty string videoPlayer.load(); // Crucial: Resets the media element to its initial state console.log("Native video element reset."); // Ensure modal is ready for next video by clearing title etc. (optional but good practice) modalVideoTitle.textContent = 'Video Title'; // Reset title placeholder // Clear any existing feedback elements (double-tap indicators) const feedbacks = document.querySelectorAll('.tap-feedback'); feedbacks.forEach(el => el.remove()); console.log("Player cleanup complete."); } // Show toast message function showToast(message, type = 'info') { const toastContainer = document.getElementById('toastContainer'); const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.textContent = message; toastContainer.appendChild(toast); // Trigger animation/transition requestAnimationFrame(() => { toast.style.opacity = 1; toast.style.transform = 'translateY(0)'; }); // Remove toast after duration setTimeout(() => { toast.style.opacity = 0; toast.style.transform = 'translateY(20px)'; // Remove from DOM after transition toast.addEventListener('transitionend', () => toast.remove()); // Fallback removal if transitionend doesn't fire setTimeout(() => toast.remove(), 500); }, 3000); // Keep toast visible for 3 seconds } });