videofolxtv/client/src/components/hls-preview-thumbnail.tsx
sebastjanartic 809fdf8fb1 Improve video preview loading and display on the platform
Refactor HLS preview thumbnail to load videos on hover, introduce MP4 fallback, and optimize HLS settings. Update VideoCard to display static thumbnails until hover, preventing unnecessary initial video loading.

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/QjWGafC
2025-08-07 12:51:03 +00:00

278 lines
9.5 KiB
TypeScript

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<string | null>(null);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [hlsInstance, setHlsInstance] = useState<Hls | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hoverTimeoutRef = useRef<NodeJS.Timeout>();
const previewIntervalRef = useRef<NodeJS.Timeout>();
// Initialize HLS for preview only when hovering
const initializeVideoOnDemand = () => {
if (!video.videoUrl.includes('.m3u8') && !video.videoUrlMp4) return;
const videoElement = videoRef.current;
if (!videoElement || isVideoLoaded || hlsInstance) return;
let hls: Hls | null = null;
// Use MP4 if available for better seek performance
if (video.videoUrlMp4) {
videoElement.src = video.videoUrlMp4;
videoElement.muted = true;
videoElement.playsInline = true;
videoElement.preload = 'metadata';
const handleLoadedMetadata = () => {
setIsVideoLoaded(true);
videoElement.removeEventListener('loadedmetadata', handleLoadedMetadata);
};
videoElement.addEventListener('loadedmetadata', handleLoadedMetadata);
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: 2,
maxBufferLength: 10, // Minimal buffering for previews
lowLatencyMode: true,
});
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);
}
};
// Clean up on unmount
useEffect(() => {
return () => {
if (hlsInstance) {
hlsInstance.destroy();
}
};
}, [hlsInstance]);
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 (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
hoverTimeoutRef.current = setTimeout(() => {
setIsHovering(true);
// Initialize video only when user actually hovers
initializeVideoOnDemand();
if (isVideoLoaded) {
generatePreviewThumbnail(video.duration * 0.25); // Start at 25% of video
}
}, 300); // Reduced delay for better UX
};
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 (
<div className={`group cursor-pointer ${className}`}>
{/* Hidden video element for thumbnail generation */}
<video
ref={videoRef}
className="hidden"
muted
playsInline
crossOrigin="anonymous"
onError={() => setIsVideoLoaded(false)}
/>
<div
ref={containerRef}
className="relative bg-bunny-gray rounded-xl overflow-hidden mb-4 aspect-video"
onClick={() => onClick(video)}
onMouseMove={handleMouseMove}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
data-testid={`hls-preview-thumbnail-${video.id}`}
>
{/* Main thumbnail or preview */}
<img
src={isHovering && previewThumbnail ? previewThumbnail : video.thumbnailUrl}
alt={video.title}
className="w-full h-full object-cover transition-all duration-300 group-hover:scale-105"
data-testid={`img-hls-preview-${video.id}`}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
// Show fallback background
if (target.parentElement) {
target.parentElement.style.background = 'linear-gradient(45deg, #374151, #4b5563)';
}
}}
/>
{/* Fallback for missing thumbnails */}
<div className="absolute inset-0 bg-gradient-to-br from-gray-600 to-gray-700 flex items-center justify-center opacity-0">
<div className="text-white text-center">
<Play className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm opacity-75">Video Preview</p>
</div>
</div>
{/* Overlay with play button */}
<div className={`absolute inset-0 bg-black/20 group-hover:bg-black/40 transition-all duration-300 flex items-center justify-center ${
isHovering ? 'bg-black/50' : ''
}`}>
<div className={`bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center group-hover:bg-white/30 transition-all duration-300 ${
isHovering ? 'w-20 h-20 bg-white/40 scale-110' : 'w-16 h-16'
}`}>
<Play className={`text-white ml-1 transition-all duration-300 ${
isHovering ? 'text-2xl' : 'text-xl'
}`} />
</div>
</div>
{/* Duration badge */}
<div className="absolute bottom-3 right-3 bg-black/80 px-2 py-1 rounded text-xs font-medium">
<span data-testid={`text-duration-${video.id}`}>
{formatDuration(video.duration)}
</span>
</div>
{/* Preview controls when hovering */}
{isHovering && isVideoLoaded && (
<>
{/* Scrub bar */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/20">
<div
className="h-full bg-bunny-blue transition-all duration-150"
style={{ width: `${(previewTime / video.duration) * 100}%` }}
/>
</div>
{/* Time tooltip */}
<div
className="absolute bottom-12 bg-black/90 text-white text-xs px-2 py-1 rounded pointer-events-none transition-all duration-150 z-10"
style={{
left: `${Math.max(5, Math.min(95, (previewTime / video.duration) * 100))}%`,
transform: 'translateX(-50%)'
}}
>
{formatTime(previewTime)}
</div>
{/* Preview indicator */}
<div className="absolute top-2 right-2 bg-bunny-blue/80 text-white text-xs px-2 py-1 rounded">
Preview
</div>
</>
)}
{/* Loading state */}
{!isVideoLoaded && (
<div className="absolute top-2 left-2 flex items-center space-x-1">
<div className="w-2 h-2 bg-bunny-blue rounded-full animate-pulse"></div>
<div className="w-2 h-2 bg-bunny-blue rounded-full animate-pulse animation-delay-75"></div>
<div className="w-2 h-2 bg-bunny-blue rounded-full animate-pulse animation-delay-150"></div>
</div>
)}
</div>
</div>
);
}