Update z-index for modals to ensure they display correctly, adjust carousel scroll amount and behavior, and refine video card z-indexing. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 2eb1084e-b728-4449-9231-f1665924c8d5 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/2eb1084e-b728-4449-9231-f1665924c8d5/QCN70f2
249 lines
8.8 KiB
TypeScript
249 lines
8.8 KiB
TypeScript
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<NodeJS.Timeout>();
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const hlsRef = useRef<any>(null);
|
|
const animationFrameRef = useRef<number>();
|
|
|
|
// Handle mouse scrubbing for video preview with throttling for smoothness
|
|
const lastScrubTime = useRef(0);
|
|
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
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 (
|
|
<div
|
|
data-testid={`card-video-${video.id}`}
|
|
className={`video-card transition-all duration-500 ease-out hover:scale-[1.35] p-1 md:p-2 ${className}`}
|
|
style={{
|
|
transformStyle: 'preserve-3d',
|
|
transition: 'transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94), z-index 0.1s ease',
|
|
willChange: 'transform',
|
|
zIndex: isHovered ? 5 : 1,
|
|
position: isHovered ? 'relative' : 'static'
|
|
}}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
>
|
|
{/* Video preview container */}
|
|
<div
|
|
className="relative gradient-card rounded-lg overflow-hidden mb-2 aspect-[9/16] md:aspect-[16/9] cursor-pointer group"
|
|
onClick={() => onClick?.(video)}
|
|
onMouseMove={handleMouseMove}
|
|
>
|
|
{/* Static thumbnail - always visible */}
|
|
<img
|
|
src={video.thumbnailUrl}
|
|
alt={video.title}
|
|
className={`w-full h-full object-cover transition-all duration-500 ease-out ${showPreview ? 'opacity-0' : 'opacity-100 group-hover:scale-105'}`}
|
|
style={{
|
|
objectPosition: video.faceCenterPosition || 'center center',
|
|
objectFit: 'cover'
|
|
}}
|
|
data-testid={`img-thumbnail-${video.id}`}
|
|
loading="lazy"
|
|
decoding="async"
|
|
onError={(e) => {
|
|
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 = '<div style="font-size: 28px; margin-bottom: 4px;">🎬</div><div style="font-size: 10px; opacity: 0.7;">Video</div>';
|
|
target.parentElement.appendChild(placeholder);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* Video preview - only load when hovering */}
|
|
{showPreview && (
|
|
<div className="absolute inset-0">
|
|
<video
|
|
ref={videoRef}
|
|
className="w-full h-full object-cover"
|
|
style={{
|
|
objectPosition: video.faceCenterPosition || 'center center',
|
|
objectFit: 'cover'
|
|
}}
|
|
muted={false} // Enable audio for preview
|
|
loop
|
|
playsInline
|
|
preload="none"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Duration badge */}
|
|
<div className="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded z-10">
|
|
{formatDuration(video.duration)}
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<h3
|
|
className="text-sm md:text-base font-medium line-clamp-2 hover:text-bunny-blue transition-colors duration-300 ease-out text-bunny-light cursor-pointer"
|
|
onClick={() => onClick?.(video)}
|
|
data-testid={`text-title-${video.id}`}
|
|
>
|
|
{video.title}
|
|
</h3>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|