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
This commit is contained in:
parent
f6e7dcc114
commit
0adbf9900b
257
client/src/components/hls-preview-thumbnail.tsx
Normal file
257
client/src/components/hls-preview-thumbnail.tsx
Normal file
@ -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<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
|
||||||
|
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 (
|
||||||
|
<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}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { Play } from "lucide-react";
|
import { Play } from "lucide-react";
|
||||||
import { type Video } from "@shared/schema";
|
import { type Video } from "@shared/schema";
|
||||||
|
import HLSPreviewThumbnail from "./hls-preview-thumbnail";
|
||||||
|
|
||||||
interface VideoCardProps {
|
interface VideoCardProps {
|
||||||
video: Video;
|
video: Video;
|
||||||
@ -41,35 +42,17 @@ function formatDate(date: Date | string): string {
|
|||||||
|
|
||||||
export default function VideoCard({ video, onClick }: VideoCardProps) {
|
export default function VideoCard({ video, onClick }: VideoCardProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div data-testid={`card-video-${video.id}`}>
|
||||||
className="group cursor-pointer"
|
<HLSPreviewThumbnail
|
||||||
onClick={() => onClick(video)}
|
video={video}
|
||||||
data-testid={`card-video-${video.id}`}
|
onClick={onClick}
|
||||||
>
|
className=""
|
||||||
<div className="relative bg-bunny-gray rounded-xl overflow-hidden mb-4 aspect-video">
|
/>
|
||||||
<img
|
|
||||||
src={video.thumbnailUrl}
|
|
||||||
alt={video.title}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
|
||||||
data-testid={`img-thumbnail-${video.id}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/40 transition-colors duration-300 flex items-center justify-center">
|
|
||||||
<div className="w-16 h-16 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center group-hover:bg-white/30 transition-colors duration-300">
|
|
||||||
<Play className="text-white text-xl ml-1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3
|
<h3
|
||||||
className="font-semibold line-clamp-2 group-hover:text-bunny-blue transition-colors text-bunny-light"
|
className="font-semibold line-clamp-2 hover:text-bunny-blue transition-colors text-bunny-light cursor-pointer"
|
||||||
|
onClick={() => onClick(video)}
|
||||||
data-testid={`text-title-${video.id}`}
|
data-testid={`text-title-${video.id}`}
|
||||||
>
|
>
|
||||||
{video.title}
|
{video.title}
|
||||||
|
|||||||
205
client/src/components/video-preview-thumbnail.tsx
Normal file
205
client/src/components/video-preview-thumbnail.tsx
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { Play } from "lucide-react";
|
||||||
|
import { type Video } from "@shared/schema";
|
||||||
|
|
||||||
|
interface VideoPreviewThumbnailProps {
|
||||||
|
video: Video;
|
||||||
|
onClick: (video: Video) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoPreviewThumbnail({ video, onClick, className = "" }: VideoPreviewThumbnailProps) {
|
||||||
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
|
const [previewTime, setPreviewTime] = useState(0);
|
||||||
|
const [previewThumbnail, setPreviewThumbnail] = useState<string | null>(null);
|
||||||
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hoverTimeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
const previewIntervalRef = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
|
// Create hidden video element for thumbnail generation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!video.videoUrlMp4 && !video.videoUrl.includes('.mp4')) return;
|
||||||
|
|
||||||
|
const videoElement = videoRef.current;
|
||||||
|
if (!videoElement) return;
|
||||||
|
|
||||||
|
const videoSrc = video.videoUrlMp4 || video.videoUrl;
|
||||||
|
videoElement.src = videoSrc;
|
||||||
|
videoElement.muted = true;
|
||||||
|
videoElement.playsInline = true;
|
||||||
|
videoElement.preload = 'metadata';
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
setIsVideoLoaded(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
videoElement.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
videoElement.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
};
|
||||||
|
}, [video.videoUrlMp4, video.videoUrl]);
|
||||||
|
|
||||||
|
const generatePreviewThumbnail = (time: number) => {
|
||||||
|
const videoElement = videoRef.current;
|
||||||
|
if (!videoElement || !isVideoLoaded) return;
|
||||||
|
|
||||||
|
videoElement.currentTime = time;
|
||||||
|
|
||||||
|
const handleSeeked = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
canvas.width = videoElement.videoWidth;
|
||||||
|
canvas.height = videoElement.videoHeight;
|
||||||
|
|
||||||
|
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const thumbnailUrl = canvas.toDataURL('image/jpeg', 0.8);
|
||||||
|
setPreviewThumbnail(thumbnailUrl);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (!isVideoLoaded) return;
|
||||||
|
|
||||||
|
// Delay before showing preview to avoid flickering
|
||||||
|
hoverTimeoutRef.current = setTimeout(() => {
|
||||||
|
setIsHovering(true);
|
||||||
|
generatePreviewThumbnail(0);
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
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"
|
||||||
|
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={`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-200"
|
||||||
|
data-testid={`img-preview-${video.id}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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' : '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 time indicator */}
|
||||||
|
{isHovering && isVideoLoaded && (
|
||||||
|
<>
|
||||||
|
{/* Progress 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"
|
||||||
|
style={{
|
||||||
|
left: `${(previewTime / video.duration) * 100}%`,
|
||||||
|
transform: 'translateX(-50%)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(previewTime)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading indicator for video metadata */}
|
||||||
|
{!isVideoLoaded && (video.videoUrlMp4 || video.videoUrl.includes('.mp4')) && (
|
||||||
|
<div className="absolute top-2 left-2">
|
||||||
|
<div className="w-2 h-2 bg-bunny-blue rounded-full animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -132,4 +132,13 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Animation delays for loading indicators */
|
||||||
|
.animation-delay-75 {
|
||||||
|
animation-delay: 75ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-delay-150 {
|
||||||
|
animation-delay: 150ms;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,8 @@ g4.video is a fully functional professional video streaming platform that integr
|
|||||||
- ✅ **YouTube-Style Editing**: Complete video editing interface with title, description, category, tags, and privacy controls
|
- ✅ **YouTube-Style Editing**: Complete video editing interface with title, description, category, tags, and privacy controls
|
||||||
- ✅ **Interactive Thumbnail Generator**: Advanced thumbnail creation from any video frame with timeline scrubbing, custom image upload, and real-time preview
|
- ✅ **Interactive Thumbnail Generator**: Advanced thumbnail creation from any video frame with timeline scrubbing, custom image upload, and real-time preview
|
||||||
- ✅ **Copy Link Feature**: Easy link copying with visual feedback notifications
|
- ✅ **Copy Link Feature**: Easy link copying with visual feedback notifications
|
||||||
|
- ✅ **Admin Dashboard**: Comprehensive admin interface with video statistics, management tools, and editing capabilities
|
||||||
|
- ✅ **Interactive Video Preview Thumbnails**: Advanced hover-based video previews with scrub bar, time tooltips, and frame-accurate seeking using HLS and MP4 streams
|
||||||
|
|
||||||
## User Preferences
|
## User Preferences
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user