Spaces:
Running
Running
| 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'); | |
| // 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 = ` | |
| <div class="error-container" style="grid-column: 1 / -1;"> | |
| <i class="fas fa-search"></i> | |
| <p>No videos found matching your search.</p> | |
| </div> | |
| `; | |
| return; | |
| } | |
| // Calculate pagination | |
| const start = (currentPage - 1) * videosPerPage; | |
| const end = Math.min(start + videosPerPage, filteredVideos.length); | |
| const currentPageVideos = filteredVideos.slice(start, end); | |
| // 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 = ` | |
| <div class="video-thumbnail"> | |
| <div class="thumbnail-placeholder"> | |
| <i class="fas fa-film fa-2x"></i> | |
| </div> | |
| <div class="play-icon"> | |
| <i class="fas fa-play fa-lg"></i> | |
| </div> | |
| </div> | |
| <div class="video-info"> | |
| <h3 class="video-title">${video.title}</h3> | |
| </div> | |
| `; | |
| 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 = '<i class="fas fa-chevron-left"></i>'; | |
| 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 = 2; // Number of pages to show on each side of current | |
| const range = []; | |
| const rangeWithDots = []; | |
| let l; | |
| range.push(1); | |
| for (let i = currentPage - delta; i <= currentPage + delta; i++) { | |
| if (i > 1 && i < totalPages) { | |
| range.push(i); | |
| } | |
| } | |
| range.push(totalPages); | |
| for (let i of range) { | |
| if (l) { | |
| if (i - l === 2) { | |
| rangeWithDots.push(l + 1); | |
| } else if (i - l !== 1) { | |
| rangeWithDots.push('...'); | |
| } | |
| } | |
| rangeWithDots.push(i); | |
| l = i; | |
| } | |
| 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 = '<i class="fas fa-chevron-right"></i>'; | |
| 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; | |
| updateVideoCount(); | |
| renderPagination(); | |
| renderVideos(); | |
| // Scroll to top of video grid | |
| videoGrid.scrollIntoView({ behavior: 'smooth' }); | |
| } | |
| // Play video in modal | |
| function playVideo(video) { | |
| // Clean up any existing player | |
| cleanupPlayer(); | |
| // 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 | |
| videoPlayer.src = videoUrl; | |
| // Initialize Plyr | |
| 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 } | |
| }); | |
| // 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 | |
| // Play the video (with error handling) | |
| try { | |
| currentPlayer.play().catch(error => { | |
| console.warn('Failed to autoplay video:', error); | |
| // Check if it's a format issue | |
| const fileExtension = videoUrl.split('.').pop().toLowerCase(); | |
| if (fileExtension === 'mkv') { | |
| showToast('MKV format may not be fully supported in your browser.', 'warning'); | |
| } | |
| }); | |
| } catch (error) { | |
| console.error('Error playing video:', error); | |
| showToast('Error playing video. Please try again.', 'error'); | |
| } | |
| } | |
| // Set up double-tap controls for mobile | |
| function setupDoubleTapControls() { | |
| const playerElement = videoPlayer.closest('.player-container'); | |
| const handleTap = (e) => { | |
| const now = Date.now(); | |
| const tapX = e.touches ? e.touches[0].clientX : e.clientX; | |
| const playerWidth = playerElement.offsetWidth; | |
| const tapPosition = tapX / playerWidth; // 0 to 1 position | |
| if (now - lastTapTime < 300 && Math.abs(tapX - lastDoubleTapX) < doubleTapThreshold) { | |
| // Double tap detected | |
| e.preventDefault(); | |
| if (tapPosition < 0.3) { | |
| // Double tap on left side - rewind | |
| currentPlayer.rewind(10); | |
| showControlFeedback('left', 'Rewind 10s'); | |
| } else if (tapPosition > 0.7) { | |
| // Double tap on right side - forward | |
| currentPlayer.forward(10); | |
| showControlFeedback('right', 'Forward 10s'); | |
| } else { | |
| // Double tap in center - play/pause | |
| if (currentPlayer.playing) { | |
| currentPlayer.pause(); | |
| } else { | |
| currentPlayer.play(); | |
| } | |
| } | |
| } | |
| lastTapTime = now; | |
| lastDoubleTapX = tapX; | |
| }; | |
| // Add touch event listeners | |
| playerElement.addEventListener('touchstart', handleTap); | |
| // Store for cleanup | |
| playerElement.handleTapFunc = handleTap; | |
| } | |
| // Visual feedback for touch controls | |
| function showControlFeedback(position, text) { | |
| const playerElement = videoPlayer.closest('.player-container'); | |
| // Create a feedback element | |
| const feedback = document.createElement('div'); | |
| feedback.className = `tap-feedback ${position}`; | |
| feedback.innerHTML = ` | |
| <div class="feedback-icon"> | |
| <i class="fas fa-${position === 'left' ? 'backward' : position === 'right' ? 'forward' : 'play'}"></i> | |
| </div> | |
| <div class="feedback-text">${text}</div> | |
| `; | |
| // Style it | |
| feedback.style.position = 'absolute'; | |
| feedback.style.top = '50%'; | |
| feedback.style.transform = 'translateY(-50%)'; | |
| feedback.style.color = 'white'; | |
| feedback.style.backgroundColor = 'rgba(0,0,0,0.6)'; | |
| feedback.style.padding = '15px'; | |
| feedback.style.borderRadius = '50%'; | |
| feedback.style.display = 'flex'; | |
| feedback.style.flexDirection = 'column'; | |
| feedback.style.alignItems = 'center'; | |
| feedback.style.opacity = '0'; | |
| feedback.style.animation = 'fadeInOut 1s ease-in-out'; | |
| if (position === 'left') { | |
| feedback.style.left = '15%'; | |
| } else if (position === 'right') { | |
| feedback.style.right = '15%'; | |
| } else { | |
| feedback.style.left = '50%'; | |
| feedback.style.transform = 'translate(-50%, -50%)'; | |
| } | |
| // Add animation | |
| const keyframes = ` | |
| @keyframes fadeInOut { | |
| 0% { opacity: 0; } | |
| 20% { opacity: 1; } | |
| 80% { opacity: 1; } | |
| 100% { opacity: 0; } | |
| } | |
| `; | |
| const style = document.createElement('style'); | |
| style.textContent = keyframes; | |
| document.head.appendChild(style); | |
| // Add to player | |
| playerElement.appendChild(feedback); | |
| // Remove after animation | |
| setTimeout(() => { | |
| feedback.remove(); | |
| style.remove(); | |
| }, 1000); | |
| } | |
| // 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 | |
| } | |
| // Clean up video player | |
| function cleanupPlayer() { | |
| // If a player instance exists, destroy it | |
| if (currentPlayer) { | |
| // Make sure video is paused | |
| try { | |
| currentPlayer.pause(); | |
| } catch (e) { | |
| console.warn('Error pausing player:', e); | |
| } | |
| // Try-catch to handle potential Plyr errors | |
| try { | |
| currentPlayer.destroy(); | |
| } catch (e) { | |
| console.warn('Error destroying Plyr instance:', e); | |
| } | |
| currentPlayer = null; | |
| } | |
| // Directly manipulate the video element as a failsafe | |
| videoPlayer.pause(); | |
| videoPlayer.removeAttribute('src'); | |
| videoPlayer.load(); // Important: this resets the video element | |
| // Remove any event listeners from the player container | |
| const playerElement = videoPlayer.closest('.player-container'); | |
| if (playerElement && playerElement.handleTapFunc) { | |
| playerElement.removeEventListener('touchstart', playerElement.handleTapFunc); | |
| delete playerElement.handleTapFunc; | |
| } | |
| // Clear any existing feedback elements | |
| const feedbacks = document.querySelectorAll('.tap-feedback'); | |
| feedbacks.forEach(el => el.remove()); | |
| } | |
| // 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); | |
| // Remove toast after animation completes | |
| setTimeout(() => { | |
| toast.remove(); | |
| }, 3000); | |
| } | |
| }); |