videofolxtv/client/src/components/hls-preview-thumbnail.tsx
sebastjanartic 72bded4b3d Introduce a new custom loading spinner component with animations
Replace existing simple loading indicators with a new animated `LoadingSpinner` component across various pages and components, including `HLSPreviewThumbnail`, `NetflixGrid`, `ThumbnailGenerator`, `VideoAds`, `VideoGrid`, `FolxStadlPage`, `GeschichteLiedPage`, `GipfelstammtischPage`, and `VideoPage`. The new component features a customizable size, text, and a pulsing gradient animation, improving the visual feedback during loading states.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 890577b1-c154-40a4-a177-a0c6d55320c3
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/890577b1-c154-40a4-a177-a0c6d55320c3/UbX8tN8
2025-09-02 10:38:08 +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">
<div className="w-6 h-6 gradient-primary rounded flex items-center justify-center animate-pulse">
<div className="w-0 h-0 border-l-[4px] border-l-white border-y-[3px] border-y-transparent"></div>
</div>
</div>
)}
</div>
</div>
);
}