Spaces:
Running
Running
| /** | |
| * VideoCharm - Video Player | |
| * Handles video playback and interaction | |
| */ | |
| const VideoPlayer = (function() { | |
| // DOM Elements | |
| let videoFeed; | |
| let progressFill; | |
| let playPauseOverlay; | |
| let playIcon; | |
| let pauseIcon; | |
| let volumeControl; | |
| let volumeOnIcon; | |
| let volumeOffIcon; | |
| let likeButton; | |
| let favoriteButton; | |
| let likeCount; | |
| let favoriteCount; | |
| // State | |
| let videos = []; | |
| let currentIndex = 0; | |
| let touchStartY = 0; | |
| let touchEndY = 0; | |
| let isLoading = false; | |
| let isMuted = false; | |
| let videoLoadTimeout; | |
| /** | |
| * Initialize the video player | |
| */ | |
| function init() { | |
| // Get DOM elements | |
| videoFeed = document.getElementById('videoFeed'); | |
| progressFill = document.getElementById('progressFill'); | |
| playPauseOverlay = document.getElementById('playPauseOverlay'); | |
| playIcon = document.getElementById('playIcon'); | |
| pauseIcon = document.getElementById('pauseIcon'); | |
| volumeControl = document.getElementById('volumeControl'); | |
| volumeOnIcon = document.getElementById('volumeOnIcon'); | |
| volumeOffIcon = document.getElementById('volumeOffIcon'); | |
| likeButton = document.getElementById('likeButton'); | |
| favoriteButton = document.getElementById('favoriteButton'); | |
| likeCount = document.getElementById('likeCount'); | |
| favoriteCount = document.getElementById('favoriteCount'); | |
| // Set up event listeners | |
| setupEventListeners(); | |
| } | |
| /** | |
| * Set up event listeners for player controls | |
| */ | |
| function setupEventListeners() { | |
| // Touch swipe detection | |
| document.addEventListener('touchstart', handleTouchStart); | |
| document.addEventListener('touchend', handleTouchEnd); | |
| // Video feed click | |
| videoFeed.addEventListener('click', handleVideoClick); | |
| // Volume control | |
| volumeControl.addEventListener('click', toggleMute); | |
| // Action buttons | |
| likeButton.addEventListener('click', toggleLike); | |
| favoriteButton.addEventListener('click', toggleFavorite); | |
| } | |
| /** | |
| * Load videos from the API | |
| * @param {Number} count Number of videos to load | |
| * @param {Boolean} showLoading Whether to show loading screen | |
| * @returns {Promise} Promise that resolves when videos are loaded | |
| */ | |
| function loadVideos(count = 1, showLoading = true) { | |
| if (isLoading) return Promise.resolve(); | |
| isLoading = true; | |
| if (showLoading) { | |
| App.showLoading(); | |
| } | |
| // Clear any existing timeout | |
| if (videoLoadTimeout) { | |
| clearTimeout(videoLoadTimeout); | |
| } | |
| // Set a timeout to ensure loading doesn't hang | |
| videoLoadTimeout = setTimeout(() => { | |
| if (isLoading) { | |
| isLoading = false; | |
| if (showLoading) { | |
| App.hideLoading(); | |
| } | |
| App.showToast('加载超时,请检查网络后重试'); | |
| } | |
| }, 8000); | |
| return fetch(`/api/videos?count=${count}`) | |
| .then(response => { | |
| if (!response.ok) { | |
| throw new Error('Network response was not ok'); | |
| } | |
| return response.json(); | |
| }) | |
| .then(data => { | |
| if (data.success && data.videos && data.videos.length > 0) { | |
| // Immediately create and start playing the first video | |
| const firstVideo = data.videos[0]; | |
| createVideoElement(firstVideo.url); | |
| // If this is the first video, play it immediately | |
| if (videos.length === 1) { | |
| playCurrentVideo(); | |
| if (showLoading) { | |
| App.hideLoading(); | |
| } | |
| } | |
| // Asynchronously load remaining videos without blocking UI | |
| if (data.videos.length > 1) { | |
| setTimeout(() => { | |
| for (let i = 1; i < data.videos.length; i++) { | |
| createVideoElement(data.videos[i].url); | |
| } | |
| }, 300); | |
| } | |
| } else { | |
| throw new Error('No videos returned from API'); | |
| } | |
| }) | |
| .catch(error => { | |
| console.error('Error loading videos:', error); | |
| App.showToast('加载视频失败,请重试'); | |
| }) | |
| .finally(() => { | |
| clearTimeout(videoLoadTimeout); | |
| isLoading = false; | |
| if (showLoading) { | |
| App.hideLoading(); | |
| } | |
| }); | |
| } | |
| /** | |
| * Create a new video element | |
| * @param {String} videoUrl URL of the video | |
| */ | |
| function createVideoElement(videoUrl) { | |
| if (!videoUrl) return; | |
| // Create container | |
| const videoCard = document.createElement('div'); | |
| videoCard.className = 'video-card'; | |
| videoCard.style.transform = `translateY(${100 * videos.length}%)`; | |
| // Create video element | |
| const video = document.createElement('video'); | |
| video.src = videoUrl; | |
| video.loop = true; | |
| video.preload = 'auto'; | |
| video.muted = isMuted; | |
| video.playsInline = true; | |
| video.setAttribute('webkit-playsinline', ''); | |
| // Generate random data for the video | |
| const videoId = generateVideoId(); | |
| const videoTitle = `精彩视频 #${videos.length + 1}`; | |
| const videoDesc = getRandomDescription(); | |
| const randomLikes = Math.floor(Math.random() * 1000) + 1; | |
| // Create video object | |
| const videoObj = { | |
| id: videoId, | |
| url: videoUrl, | |
| title: videoTitle, | |
| desc: videoDesc, | |
| element: videoCard, | |
| video: video, | |
| likes: randomLikes, | |
| timestamp: new Date().getTime(), | |
| duration: 0 | |
| }; | |
| // Create video overlay with info | |
| const overlay = document.createElement('div'); | |
| overlay.className = 'video-overlay'; | |
| const videoInfo = document.createElement('div'); | |
| videoInfo.className = 'video-info'; | |
| const titleEl = document.createElement('div'); | |
| titleEl.className = 'video-title'; | |
| titleEl.textContent = videoObj.title; | |
| const descEl = document.createElement('div'); | |
| descEl.className = 'video-desc'; | |
| descEl.textContent = videoObj.desc; | |
| const metaEl = document.createElement('div'); | |
| metaEl.className = 'video-meta'; | |
| const durationEl = document.createElement('div'); | |
| durationEl.className = 'video-meta-item duration'; | |
| durationEl.innerHTML = ` | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | |
| <path d="M12 2C6.5 2 2 6.5 2 12C2 17.5 6.5 22 12 22C17.5 22 22 17.5 22 12C22 6.5 17.5 2 12 2ZM12 20C7.59 20 4 16.41 4 12C4 7.59 7.59 4 12 4C16.41 4 20 7.59 20 12C20 16.41 16.41 20 12 20ZM12.5 7H11V13L16.2 16.2L17 14.9L12.5 12.2V7Z"/> | |
| </svg> | |
| <span>00:00</span> | |
| `; | |
| metaEl.appendChild(durationEl); | |
| videoInfo.appendChild(titleEl); | |
| videoInfo.appendChild(descEl); | |
| videoInfo.appendChild(metaEl); | |
| overlay.appendChild(videoInfo); | |
| // Add elements to DOM | |
| videoCard.appendChild(video); | |
| videoCard.appendChild(overlay); | |
| videoFeed.appendChild(videoCard); | |
| // Add to videos array | |
| videos.push(videoObj); | |
| // Set up video event listeners | |
| setupVideoEvents(video, videoObj, durationEl); | |
| } | |
| /** | |
| * Set up event listeners for a video | |
| * @param {HTMLElement} video Video element | |
| * @param {Object} videoObj Video object | |
| * @param {HTMLElement} durationEl Duration display element | |
| */ | |
| function setupVideoEvents(video, videoObj, durationEl) { | |
| // Use video's first frame as thumbnail when available | |
| video.addEventListener('loadeddata', function() { | |
| videoObj.hasLoadedData = true; | |
| // Capture first frame as thumbnail | |
| videoObj.thumbnail = captureVideoFrame(video); | |
| }); | |
| // Update duration when metadata is loaded | |
| video.addEventListener('loadedmetadata', function() { | |
| videoObj.duration = Math.round(video.duration); | |
| const durationSpan = durationEl.querySelector('span'); | |
| if (durationSpan) { | |
| durationSpan.textContent = formatDuration(videoObj.duration); | |
| } | |
| }); | |
| // Update progress bar | |
| video.addEventListener('timeupdate', function() { | |
| if (videos.indexOf(videoObj) === currentIndex) { | |
| const progress = (video.currentTime / video.duration) * 100; | |
| progressFill.style.width = `${progress}%`; | |
| } | |
| }); | |
| // Handle playback end (for non-looping videos) | |
| video.addEventListener('ended', function() { | |
| if (!video.loop) { | |
| // Automatically advance to next video | |
| switchToVideo(currentIndex + 1); | |
| } | |
| }); | |
| // Handle video errors | |
| video.addEventListener('error', function() { | |
| console.error('Video load error:', videoObj.url); | |
| App.showToast('视频加载失败,正在尝试下一个'); | |
| if (videos.indexOf(videoObj) === currentIndex) { | |
| switchToVideo(currentIndex + 1); | |
| } | |
| }); | |
| } | |
| /** | |
| * Capture a frame from video to use as thumbnail | |
| * @param {HTMLVideoElement} video Video element | |
| * @returns {String} Data URL of thumbnail | |
| */ | |
| function captureVideoFrame(video) { | |
| try { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = video.videoWidth; | |
| canvas.height = video.videoHeight; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(video, 0, 0, canvas.width, canvas.height); | |
| return canvas.toDataURL('image/jpeg', 0.7); | |
| } catch (e) { | |
| console.error('Error capturing video frame:', e); | |
| return null; | |
| } | |
| } | |
| /** | |
| * Switch to a different video | |
| * @param {Number} index Index of video to switch to | |
| */ | |
| function switchToVideo(index) { | |
| if (index < 0 || isLoading) return; | |
| // Handle case where requested index exceeds available videos | |
| if (index >= videos.length) { | |
| // Show loading toast and load more videos | |
| App.showToast('正在加载新视频...'); | |
| loadVideos(1, false).then(() => { | |
| // After loading, try again if video exists now | |
| if (index < videos.length) { | |
| switchToVideo(index); | |
| } | |
| }); | |
| return; | |
| } | |
| // Pause all videos | |
| videos.forEach(item => { | |
| item.video.pause(); | |
| }); | |
| // Update current index | |
| currentIndex = index; | |
| // Update video positions | |
| updateVideoPositions(); | |
| // Play current video | |
| playCurrentVideo(); | |
| // Reset progress bar | |
| progressFill.style.width = '0'; | |
| // Update action buttons | |
| updateActionButtons(); | |
| // Add to history | |
| if (videos[currentIndex]) { | |
| StorageManager.addToHistory(videos[currentIndex]); | |
| } | |
| // Preload more videos when close to end | |
| if (currentIndex >= videos.length - 1) { | |
| loadVideos(2, false); | |
| } | |
| } | |
| /** | |
| * Play the current video | |
| */ | |
| function playCurrentVideo() { | |
| if (!videos[currentIndex]) return; | |
| const video = videos[currentIndex].video; | |
| // Try to play and handle autoplay restrictions | |
| const playPromise = video.play(); | |
| if (playPromise !== undefined) { | |
| playPromise.catch(error => { | |
| console.log('Autoplay prevented, trying muted playback'); | |
| // Auto-play was prevented, set muted and try again | |
| video.muted = true; | |
| isMuted = true; | |
| updateVolumeUI(); | |
| video.play().catch(e => { | |
| console.error('Failed to play video even with mute:', e); | |
| // If even muted autoplay fails, show a play button overlay | |
| App.showToast('点击视频开始播放'); | |
| }); | |
| }); | |
| } | |
| } | |
| /** | |
| * Update positions of all videos | |
| */ | |
| function updateVideoPositions() { | |
| videos.forEach((item, idx) => { | |
| item.element.style.transform = `translateY(${(idx - currentIndex) * 100}%)`; | |
| }); | |
| } | |
| /** | |
| * Update action buttons state based on current video | |
| */ | |
| function updateActionButtons() { | |
| if (!videos[currentIndex]) return; | |
| const videoId = videos[currentIndex].id; | |
| // Update like button | |
| if (StorageManager.isLiked(videoId)) { | |
| likeButton.classList.add('active'); | |
| } else { | |
| likeButton.classList.remove('active'); | |
| } | |
| // Update favorite button | |
| if (StorageManager.isFavorite(videoId)) { | |
| favoriteButton.classList.add('active'); | |
| } else { | |
| favoriteButton.classList.remove('active'); | |
| } | |
| // Update counts | |
| likeCount.textContent = Math.floor(videos[currentIndex].likes); | |
| favoriteCount.textContent = StorageManager.isFavorite(videoId) ? '1' : '0'; | |
| } | |
| /** | |
| * Handle touch start event | |
| * @param {Event} e Touch event | |
| */ | |
| function handleTouchStart(e) { | |
| touchStartY = e.touches[0].clientY; | |
| } | |
| /** | |
| * Handle touch end event | |
| * @param {Event} e Touch event | |
| */ | |
| function handleTouchEnd(e) { | |
| // Only process touch events on home screen | |
| if (!document.getElementById('homeScreen').classList.contains('active')) { | |
| return; | |
| } | |
| touchEndY = e.changedTouches[0].clientY; | |
| const deltaY = touchStartY - touchEndY; | |
| // If swipe distance is significant | |
| if (Math.abs(deltaY) > 50) { | |
| if (deltaY > 0) { | |
| // Swipe up - next video | |
| switchToVideo(currentIndex + 1); | |
| } else { | |
| // Swipe down - previous video | |
| switchToVideo(currentIndex - 1); | |
| } | |
| } | |
| } | |
| /** | |
| * Handle click on video feed | |
| * @param {Event} e Click event | |
| */ | |
| function handleVideoClick(e) { | |
| // Ignore clicks on controls | |
| if (e.target.closest('.action-button') || | |
| e.target.closest('.volume-control')) { | |
| return; | |
| } | |
| // Toggle play/pause | |
| if (!videos[currentIndex]) return; | |
| const video = videos[currentIndex].video; | |
| if (video.paused) { | |
| video.play(); | |
| playIcon.style.display = 'block'; | |
| pauseIcon.style.display = 'none'; | |
| } else { | |
| video.pause(); | |
| playIcon.style.display = 'none'; | |
| pauseIcon.style.display = 'block'; | |
| } | |
| // Show play/pause overlay | |
| playPauseOverlay.classList.add('visible'); | |
| setTimeout(() => { | |
| playPauseOverlay.classList.remove('visible'); | |
| }, 800); | |
| } | |
| /** | |
| * Toggle mute state | |
| */ | |
| function toggleMute() { | |
| isMuted = !isMuted; | |
| // Update all videos | |
| videos.forEach(item => { | |
| item.video.muted = isMuted; | |
| }); | |
| // Update UI | |
| updateVolumeUI(); | |
| // Show toast | |
| App.showToast(isMuted ? '已静音' : '已开启声音'); | |
| } | |
| /** | |
| * Update volume control UI | |
| */ | |
| function updateVolumeUI() { | |
| if (isMuted) { | |
| volumeOnIcon.style.display = 'none'; | |
| volumeOffIcon.style.display = 'block'; | |
| } else { | |
| volumeOnIcon.style.display = 'block'; | |
| volumeOffIcon.style.display = 'none'; | |
| } | |
| } | |
| /** | |
| * Toggle like for current video | |
| */ | |
| function toggleLike() { | |
| if (!videos[currentIndex]) return; | |
| const videoId = videos[currentIndex].id; | |
| const newLikeState = StorageManager.toggleLike(videoId); | |
| // Update UI | |
| if (newLikeState) { | |
| likeButton.classList.add('active'); | |
| videos[currentIndex].likes++; | |
| App.showToast('已添加到喜欢'); | |
| } else { | |
| likeButton.classList.remove('active'); | |
| videos[currentIndex].likes--; | |
| App.showToast('已取消喜欢'); | |
| } | |
| // Update count | |
| likeCount.textContent = Math.floor(videos[currentIndex].likes); | |
| // Update profile stats | |
| App.updateStats(); | |
| } | |
| /** | |
| * Toggle favorite for current video | |
| */ | |
| function toggleFavorite() { | |
| if (!videos[currentIndex]) return; | |
| const newFavoriteState = StorageManager.toggleFavorite(videos[currentIndex]); | |
| // Update UI | |
| if (newFavoriteState) { | |
| favoriteButton.classList.add('active'); | |
| favoriteCount.textContent = '1'; | |
| App.showToast('已添加到收藏'); | |
| } else { | |
| favoriteButton.classList.remove('active'); | |
| favoriteCount.textContent = '0'; | |
| App.showToast('已取消收藏'); | |
| } | |
| // Update profile stats | |
| App.updateStats(); | |
| } | |
| /** | |
| * Play a video from the history or favorites | |
| * @param {Object} videoData Video data from storage | |
| */ | |
| function playVideoFromLibrary(videoData) { | |
| if (!videoData || !videoData.url) return; | |
| // Check if video is already loaded | |
| const existingIndex = videos.findIndex(v => v.id === videoData.id); | |
| if (existingIndex !== -1) { | |
| // Switch to existing video | |
| switchToVideo(existingIndex); | |
| } else { | |
| // Create new video element | |
| createVideoElement(videoData.url); | |
| // Switch to the new video | |
| switchToVideo(videos.length - 1); | |
| } | |
| // Switch to home screen | |
| App.switchToScreen('homeScreen'); | |
| } | |
| /** | |
| * Generate a unique video ID | |
| * @returns {String} Unique ID | |
| */ | |
| function generateVideoId() { | |
| return 'v_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now(); | |
| } | |
| /** | |
| * Format duration in seconds to MM:SS | |
| * @param {Number} seconds Duration in seconds | |
| * @returns {String} Formatted duration | |
| */ | |
| function formatDuration(seconds) { | |
| if (!seconds) return '00:00'; | |
| const minutes = Math.floor(seconds / 60); | |
| const remainingSeconds = Math.floor(seconds % 60); | |
| return `${minutes < 10 ? '0' : ''}${minutes}:${remainingSeconds < 10 ? '0' : ''}${remainingSeconds}`; | |
| } | |
| /** | |
| * Get random video description | |
| * @returns {String} Random description | |
| */ | |
| function getRandomDescription() { | |
| const descriptions = [ | |
| "精选高质量视频,带给你视觉享受", | |
| "每日精选,让你流连忘返", | |
| "热门推荐,不容错过的精彩瞬间", | |
| "探索更多精彩内容,尽在VideoCharm", | |
| "发现生活中的美好瞬间", | |
| "精彩纷呈,尽在眼前", | |
| "让心情放松的精选内容", | |
| "视觉盛宴,触手可及" | |
| ]; | |
| return descriptions[Math.floor(Math.random() * descriptions.length)]; | |
| } | |
| // Public API | |
| return { | |
| init: init, | |
| loadVideos: loadVideos, | |
| switchToVideo: switchToVideo, | |
| playVideoFromLibrary: playVideoFromLibrary, | |
| updateVideoPositions: updateVideoPositions | |
| }; | |
| })(); |