videofolxtv/client/src/components/video-card.tsx
sebastjanartic b2a4cfd8d4 Enable videos to autoplay with sound on the platform
Modify the video player to automatically play videos with sound when they are loaded, and adjust the video card component to disable hover previews on mobile devices.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 344ec1e0-1186-4058-bbff-2e9619a7b1e0
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/344ec1e0-1186-4058-bbff-2e9619a7b1e0/FgaI1Sc
2025-08-30 22:47:39 +00:00

230 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;
hideOverlay?: boolean;
}
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 = "", hideOverlay = false }: 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) {
// Only enable preview on desktop, disable on mobile
if (window.innerWidth >= 768) {
const delay = 800;
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)}
>
{/* 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={window.innerWidth >= 768 ? false : true}
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-cyan-400 to-purple-500 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-cyan-300 to-purple-400 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 and hideOverlay is false */}
{!showPreview && !hideOverlay && (
<div className="absolute bottom-2 left-2 right-2 bg-black/40 text-white px-2 py-1 rounded backdrop-blur-sm z-10">
<div className="truncate font-semibold text-base bg-gradient-to-r from-cyan-400 to-purple-500 bg-clip-text text-transparent">{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>
);
}