videofolxtv/client/src/components/video-card.tsx
sebastjanartic 2c46b13de8 Update video progress bar to use orange color scheme
Modify the color palette for video progress bars and associated elements within VideoCard and VideoModal components from blue/purple to orange.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 074b0e4c-6171-43bd-aa98-f9e04623ca14
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/074b0e4c-6171-43bd-aa98-f9e04623ca14/DVZN4Rp
2025-08-30 15:32:07 +00:00

231 lines
9.0 KiB
TypeScript

import { Play } from "lucide-react";
import { type Video } from "@shared/schema";
import { useState, useRef, useEffect } from "react";
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<Hls | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
// Delay preview start to avoid loading on quick mouse passes
useEffect(() => {
if (isHovered) {
// Shorter delay on mobile for better touch experience
const isMobile = window.innerWidth < 768;
const delay = isMobile ? 500 : 800; // 500ms on mobile, 800ms on desktop
hoverTimeoutRef.current = setTimeout(() => {
setShowPreview(true);
}, delay);
} else {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
setShowPreview(false);
// Clean up HLS when not hovering
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
}
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
if (hlsRef.current) {
hlsRef.current.destroy();
}
};
}, [isHovered]);
// Initialize HLS when preview shows
useEffect(() => {
if (showPreview && videoRef.current && video.videoUrl.includes('.m3u8')) {
const videoElement = videoRef.current;
if (Hls.isSupported()) {
const hls = new Hls({
startLevel: 0, // Start with lowest quality
capLevelToPlayerSize: true,
maxBufferLength: 5, // Minimal buffering
});
hls.loadSource(video.videoUrl);
hls.attachMedia(videoElement);
hlsRef.current = hls;
hls.on(Hls.Events.MANIFEST_PARSED, () => {
videoElement.play().catch(e => console.log('Preview autoplay failed:', e));
});
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
videoElement.addEventListener('loadedmetadata', () => {
setDuration(videoElement.duration);
});
});
} else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) {
// Safari native HLS support
videoElement.src = video.videoUrl;
videoElement.addEventListener('loadedmetadata', () => {
setDuration(videoElement.duration);
});
videoElement.play().catch(e => console.log('Preview autoplay failed:', e));
}
}
}, [showPreview, video.videoUrl]);
return (
<div
data-testid={`card-video-${video.id}`}
className={`video-card transition-transform duration-200 hover:scale-[1.02] ${className}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onTouchStart={() => setIsHovered(true)}
onTouchEnd={() => setIsHovered(false)}
>
{/* Video preview container */}
<div
className="relative gradient-card rounded-xl overflow-hidden aspect-[16/9] cursor-pointer group"
onClick={() => onClick?.(video)}
>
{/* Static thumbnail - always visible */}
<img
src={video.thumbnailUrl}
alt={video.title}
className={`w-full h-full object-cover transition-all duration-300 ${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 z-10">
<video
ref={videoRef}
className="w-full h-full object-cover"
style={{
objectPosition: video.faceCenterPosition || 'center center',
objectFit: 'cover'
}}
autoPlay
muted={false}
loop
playsInline
controls={false}
disablePictureInPicture
onLoadStart={() => console.log('Preview loading for:', video.title)}
onError={(e) => console.log('Preview failed for:', video.title)}
onCanPlay={() => console.log('Preview ready for:', video.title)}
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
onMouseMove={(e) => {
if (!videoRef.current || duration === 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = x / rect.width;
const newTime = Math.max(0, Math.min(duration, percentage * duration));
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
}}
/>
{/* Video scrubbing progress bar - only show during preview */}
{duration > 0 && (
<div className="absolute bottom-1 left-2 right-2 h-2 bg-black/40 rounded-full overflow-hidden backdrop-blur-sm border border-white/20">
<div
className="h-full bg-gradient-to-r from-orange-500 via-orange-400 to-orange-600 transition-all duration-200 ease-out shadow-lg relative"
style={{ width: `${(currentTime / duration) * 100}%` }}
>
{/* Glow effect */}
<div className="absolute inset-0 bg-gradient-to-r from-orange-400 via-orange-300 to-orange-500 opacity-60 blur-sm"></div>
{/* Progress dot at the end */}
<div className="absolute right-0 top-1/2 transform translate-x-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-lg border-2 border-purple-400"></div>
</div>
</div>
)}
</div>
)}
{/* Title overlay - only show when not playing preview */}
{!showPreview && (
<div className="absolute bottom-2 left-2 right-2 bg-black/50 text-white px-2 py-1 rounded backdrop-blur-sm z-10">
<div className="truncate font-medium text-sm">{video.title}</div>
<div className="text-xs text-gray-300 flex items-center space-x-1 mt-0.5">
<span>{formatViews(video.views)}</span>
<span></span>
<span>{formatDate(video.createdAt)}</span>
</div>
</div>
)}
</div>
</div>
);
}