From 0adbf9900b0378f7758e054910f9e2a5053dc41e Mon Sep 17 00:00:00 2001 From: sebastjanartic <45803536-sebastjanartic@users.noreply.replit.com> Date: Thu, 7 Aug 2025 10:41:05 +0000 Subject: [PATCH] Add interactive video preview thumbnails on hover Integrates HLS.js and creates a new VideoPreviewThumbnail component to display interactive video previews on hover, utilizing a hidden video element for thumbnail generation and seeking. Replit-Commit-Author: Agent Replit-Commit-Session-Id: d7424866-83d1-4486-a212-ac12b4c7becf Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/d7424866-83d1-4486-a212-ac12b4c7becf/uy510Ei --- .../src/components/hls-preview-thumbnail.tsx | 257 ++++++++++++++++++ client/src/components/video-card.tsx | 35 +-- .../components/video-preview-thumbnail.tsx | 205 ++++++++++++++ client/src/index.css | 9 + replit.md | 2 + 5 files changed, 482 insertions(+), 26 deletions(-) create mode 100644 client/src/components/hls-preview-thumbnail.tsx create mode 100644 client/src/components/video-preview-thumbnail.tsx diff --git a/client/src/components/hls-preview-thumbnail.tsx b/client/src/components/hls-preview-thumbnail.tsx new file mode 100644 index 0000000..2054801 --- /dev/null +++ b/client/src/components/hls-preview-thumbnail.tsx @@ -0,0 +1,257 @@ +import { useState, useRef, useEffect } from "react"; +import { Play } from "lucide-react"; +import { type Video } from "@shared/schema"; +import Hls from "hls.js"; + +interface HLSPreviewThumbnailProps { + video: Video; + onClick: (video: Video) => void; + className?: string; +} + +export default function HLSPreviewThumbnail({ video, onClick, className = "" }: HLSPreviewThumbnailProps) { + const [isHovering, setIsHovering] = useState(false); + const [previewTime, setPreviewTime] = useState(0); + const [previewThumbnail, setPreviewThumbnail] = useState(null); + const [isVideoLoaded, setIsVideoLoaded] = useState(false); + const [hlsInstance, setHlsInstance] = useState(null); + const videoRef = useRef(null); + const containerRef = useRef(null); + const hoverTimeoutRef = useRef(); + const previewIntervalRef = useRef(); + + // Initialize HLS for preview + useEffect(() => { + if (!video.videoUrl.includes('.m3u8') && !video.videoUrlMp4) return; + + const videoElement = videoRef.current; + if (!videoElement) return; + + let hls: Hls | null = null; + + const initializeVideo = () => { + // Use MP4 if available for better seek performance + if (video.videoUrlMp4) { + videoElement.src = video.videoUrlMp4; + videoElement.muted = true; + videoElement.playsInline = true; + videoElement.preload = 'metadata'; + return; + } + + // Use HLS with reduced quality for preview + if (Hls.isSupported() && video.videoUrl.includes('.m3u8')) { + hls = new Hls({ + startLevel: 0, // Start with lowest quality for faster loading + capLevelToPlayerSize: true, + maxLoadingDelay: 1, + maxBufferLength: 5, // Minimal buffering for previews + }); + + hls.loadSource(video.videoUrl); + hls.attachMedia(videoElement); + + hls.on(Hls.Events.MANIFEST_PARSED, () => { + setIsVideoLoaded(true); + }); + + hls.on(Hls.Events.ERROR, (event, data) => { + console.warn('HLS preview error:', data); + setIsVideoLoaded(false); + }); + + setHlsInstance(hls); + } + }; + + const handleLoadedMetadata = () => { + setIsVideoLoaded(true); + }; + + videoElement.addEventListener('loadedmetadata', handleLoadedMetadata); + initializeVideo(); + + return () => { + videoElement.removeEventListener('loadedmetadata', handleLoadedMetadata); + if (hls) { + hls.destroy(); + } + }; + }, [video.videoUrl, video.videoUrlMp4]); + + const generatePreviewThumbnail = (time: number) => { + const videoElement = videoRef.current; + if (!videoElement || !isVideoLoaded) return; + + // Clamp time to video duration + const clampedTime = Math.max(0, Math.min(time, video.duration - 1)); + videoElement.currentTime = clampedTime; + + const handleSeeked = () => { + try { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + canvas.width = videoElement.videoWidth || 320; + canvas.height = videoElement.videoHeight || 180; + + ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height); + + const thumbnailUrl = canvas.toDataURL('image/jpeg', 0.7); + setPreviewThumbnail(thumbnailUrl); + } catch (error) { + console.warn('Failed to generate preview thumbnail:', error); + } + + videoElement.removeEventListener('seeked', handleSeeked); + }; + + videoElement.addEventListener('seeked', handleSeeked); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isHovering || !containerRef.current || !isVideoLoaded) return; + + const rect = containerRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const percentage = Math.max(0, Math.min(1, x / rect.width)); + const time = percentage * video.duration; + + setPreviewTime(time); + + // Throttle thumbnail generation + if (previewIntervalRef.current) { + clearTimeout(previewIntervalRef.current); + } + + previewIntervalRef.current = setTimeout(() => { + generatePreviewThumbnail(time); + }, 200); + }; + + const handleMouseEnter = () => { + if (!isVideoLoaded) return; + + // Delay before showing preview to avoid flickering + hoverTimeoutRef.current = setTimeout(() => { + setIsHovering(true); + generatePreviewThumbnail(video.duration * 0.25); // Start at 25% of video + }, 600); + }; + + const handleMouseLeave = () => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + if (previewIntervalRef.current) { + clearTimeout(previewIntervalRef.current); + } + + setIsHovering(false); + setPreviewThumbnail(null); + setPreviewTime(0); + }; + + const formatTime = (seconds: number): string => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.floor(seconds % 60); + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; + }; + + const formatDuration = (seconds: number): string => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; + }; + + return ( +
+ {/* Hidden video element for thumbnail generation */} +