import { Play, Plus, ThumbsUp, ChevronDown } from "lucide-react"; import { type Video } from "@shared/schema"; import HLSPreviewThumbnail from "./hls-preview-thumbnail"; import { useState, useRef, useEffect } from "react"; // @ts-ignore import Hls from 'hls.js'; interface VideoCardProps { video: Video; onClick: (video: Video) => void; className?: string; } function formatDuration(seconds: number): string { const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; } function formatViews(views: number): string { if (views >= 1000000) { return `${(views / 1000000).toFixed(1)}M views`; } else if (views >= 1000) { return `${(views / 1000).toFixed(1)}K views`; } return `${views} views`; } function formatDate(date: Date | string): string { const now = new Date(); const createdDate = typeof date === 'string' ? new Date(date) : date; if (!createdDate || isNaN(createdDate.getTime())) { return "Unknown"; } const diffTime = Math.abs(now.getTime() - createdDate.getTime()); const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); if (diffDays === 0) return "Today"; if (diffDays === 1) return "1 day ago"; if (diffDays < 7) return `${diffDays} days ago`; if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) > 1 ? 's' : ''} ago`; return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) > 1 ? 's' : ''} ago`; } export default function VideoCard({ video, onClick, className = "" }: VideoCardProps) { const [isHovered, setIsHovered] = useState(false); const [showPreview, setShowPreview] = useState(false); const hoverTimeoutRef = useRef(); const videoRef = useRef(null); const hlsRef = useRef(null); const animationFrameRef = useRef(); // Handle mouse scrubbing for video preview with throttling for smoothness const lastScrubTime = useRef(0); const handleMouseMove = (e: React.MouseEvent) => { if (!showPreview || !videoRef.current) return; const now = Date.now(); // Throttle scrubbing to ~60fps for smoother experience if (now - lastScrubTime.current < 16) return; lastScrubTime.current = now; const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left; const progress = Math.max(0, Math.min(1, x / rect.width)); // Scrub video based on mouse position with smooth seeking if (videoRef.current.duration && !isNaN(videoRef.current.duration)) { const targetTime = progress * videoRef.current.duration; // Use requestAnimationFrame for smoother seeking requestAnimationFrame(() => { if (videoRef.current) { videoRef.current.currentTime = targetTime; } }); } }; // Delay preview start to avoid loading on quick mouse passes // Only enable previews on desktop devices with mouse useEffect(() => { const isMobile = window.innerWidth < 768 || 'ontouchstart' in window; if (isHovered && !isMobile) { hoverTimeoutRef.current = setTimeout(() => { setShowPreview(true); }, 800); // Start preview after 800ms hover } else { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); } setShowPreview(false); } return () => { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); } }; }, [isHovered]); // Setup HLS when preview is shown useEffect(() => { if (showPreview && videoRef.current && video.videoUrl) { const videoElement = videoRef.current; if (Hls.isSupported()) { console.log('Setting up HLS preview for:', video.title); hlsRef.current = new Hls({ enableWorker: false, lowLatencyMode: true, backBufferLength: 10, maxBufferLength: 15, maxMaxBufferLength: 30, maxBufferSize: 30 * 1000 * 1000, maxBufferHole: 0.1, startLevel: -1, // Auto select lowest quality for fast start autoStartLoad: true, debug: false, liveSyncDurationCount: 3, liveMaxLatencyDurationCount: 10, startFragPrefetch: true, testBandwidth: false, }); hlsRef.current.loadSource(video.videoUrl); hlsRef.current.attachMedia(videoElement); hlsRef.current.on(Hls.Events.MANIFEST_PARSED, () => { console.log('HLS manifest parsed, starting playback'); // Enable audio and play videoElement.muted = false; videoElement.volume = 0.3; // Low volume for preview videoElement.play().catch(e => console.log('Autoplay failed:', e)); }); hlsRef.current.on(Hls.Events.ERROR, (event: any, data: any) => { console.log('HLS error:', data); }); } else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) { // Safari native HLS support videoElement.src = video.videoUrl; videoElement.muted = false; videoElement.volume = 0.3; videoElement.play().catch(e => console.log('Autoplay failed:', e)); } } return () => { if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } }; }, [showPreview, video.videoUrl]); return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {/* Video preview container */}
onClick?.(video)} onMouseMove={handleMouseMove} > {/* Static thumbnail - always visible */} {video.title} { const target = e.target as HTMLImageElement; console.log('Thumbnail failed to load:', target.src); // Show placeholder immediately instead of trying multiple URLs target.style.display = 'none'; if (target.parentElement && !target.parentElement.querySelector('.thumbnail-placeholder')) { target.parentElement.style.background = 'linear-gradient(135deg, #1f2937, #374151)'; const placeholder = document.createElement('div'); placeholder.className = 'thumbnail-placeholder absolute inset-0 flex flex-col items-center justify-center text-white'; placeholder.innerHTML = '
🎬
Video
'; target.parentElement.appendChild(placeholder); } }} /> {/* Video preview - only load when hovering */} {showPreview && (
)} {/* Duration badge */}
{formatDuration(video.duration)}

onClick?.(video)} data-testid={`text-title-${video.id}`} > {video.title}

); }