videofolxtv/client/src/components/netflix-grid.tsx
sebastjanartic 7dd25fe393 Improve video carousel looping behavior for smoother transitions
Update the initial state of the video carousel to correctly position the videos for seamless looping in both forward and backward directions.

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
2025-08-29 15:52:08 +00:00

383 lines
13 KiB
TypeScript

import { useState, useRef, useEffect } from "react";
import { type Video } from "@shared/schema";
import VideoCard from "./video-card";
import BunnyVideoModal from "./bunny-video-modal";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface VideoCategory {
title: string;
videos: Video[];
}
interface NetflixGridProps {
videos: Video[];
isLoading: boolean;
}
export default function NetflixGrid({ videos, isLoading }: NetflixGridProps) {
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const handleVideoClick = (video: Video) => {
setSelectedVideo(video);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedVideo(null);
};
const handleVideoChange = (video: Video) => {
setSelectedVideo(video);
};
// Organize videos into categories
const getCategories = (): VideoCategory[] => {
if (!videos.length) return [];
// Sort by views for top content
const sortedByViews = [...videos].sort((a, b) => (b.views || 0) - (a.views || 0));
// Sort by date for recently added
const sortedByDate = [...videos].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
return [
{
title: "Top 10 Videos Today",
videos: sortedByViews.slice(0, 10)
},
{
title: "Popular Videos",
videos: sortedByViews.slice(10, 20)
},
{
title: "Recently Added",
videos: sortedByDate.slice(0, 15)
},
{
title: "Trending Now",
videos: videos.slice(0, 12)
}
];
};
if (isLoading && videos.length === 0) {
return (
<div className="space-y-8">
{Array.from({ length: 3 }).map((_, categoryIndex) => (
<div key={categoryIndex} className="space-y-4">
<div className="h-6 bg-bunny-gray rounded w-48 animate-pulse"></div>
<div className="flex space-x-4 overflow-hidden">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="flex-shrink-0 w-56 md:w-80 animate-pulse">
<div className="bg-bunny-gray aspect-[9/16] md:aspect-[16/9] rounded-xl mb-3"></div>
<div className="space-y-2">
<div className="h-4 bg-bunny-gray rounded w-3/4"></div>
<div className="h-3 bg-bunny-gray rounded w-1/2"></div>
</div>
</div>
))}
</div>
</div>
))}
</div>
);
}
if (videos.length === 0) {
return (
<div className="text-center py-12">
<div className="text-bunny-muted text-lg mb-4">
No videos found
</div>
<p className="text-sm text-bunny-muted">
Try adjusting your search or filter criteria
</p>
</div>
);
}
const categories = getCategories();
return (
<>
<div className="space-y-12">
{categories.map((category, categoryIndex) => (
<CategoryRow
key={categoryIndex}
category={category}
onVideoClick={handleVideoClick}
/>
))}
</div>
<BunnyVideoModal
video={selectedVideo}
isOpen={isModalOpen}
onClose={handleCloseModal}
videos={videos}
onVideoChange={handleVideoChange}
/>
</>
);
}
interface CategoryRowProps {
category: VideoCategory;
onVideoClick: (video: Video) => void;
}
function CategoryRow({ category, onVideoClick }: CategoryRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const scrollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [showLeftButton, setShowLeftButton] = useState(false);
const [showRightButton, setShowRightButton] = useState(true);
const [translateX, setTranslateX] = useState(0);
const [isScrolling, setIsScrolling] = useState(false);
const [speedMode, setSpeedMode] = useState<'normal' | 'fast'>('normal');
const videosToShow = 5; // Show 5 videos at a time
const videoWidth = 120; // Width including spacing
const scroll = (direction: 'left' | 'right') => {
// Only move one step when clicked (no speed change here)
const step = direction === 'right' ? -videoWidth : videoWidth;
setTranslateX(prev => prev + step);
};
const toggleSpeed = (direction: 'left' | 'right') => {
// Toggle speed mode and restart animation with new speed
const newSpeed = speedMode === 'normal' ? 'fast' : 'normal';
setSpeedMode(newSpeed);
// Restart the animation with new speed
if (isScrolling && scrollIntervalRef.current) {
clearInterval(scrollIntervalRef.current);
// Start new interval with updated speed immediately
const baseSpeed = newSpeed === 'fast' ? 2.5 : 0.8;
const interval = newSpeed === 'fast' ? 10 : 16;
scrollIntervalRef.current = setInterval(() => {
setTranslateX(prev => {
const speed = direction === 'right' ? -baseSpeed : baseSpeed;
const newX = prev + speed;
const totalWidth = category.videos.length * videoWidth;
// Seamless infinite carousel - no jumps, just continuous flow
let adjustedX = newX;
// Keep position within bounds using modulo for seamless loop
while (adjustedX <= -totalWidth) {
adjustedX += totalWidth;
}
while (adjustedX > 0) {
adjustedX -= totalWidth;
}
return adjustedX;
});
}, interval);
}
};
const startAutoScroll = (direction: 'left' | 'right') => {
// Clear any existing interval
if (scrollIntervalRef.current) {
clearInterval(scrollIntervalRef.current);
}
setIsScrolling(true);
// Start continuous smooth scrolling with variable speed
scrollIntervalRef.current = setInterval(() => {
setTranslateX(prev => {
// Speed changes based on mode: normal (0.5px) or fast (1.2px) - smooth video by video
const baseSpeed = speedMode === 'fast' ? 1.2 : 0.5;
const speed = direction === 'right' ? -baseSpeed : baseSpeed;
const newX = prev + speed;
const totalWidth = category.videos.length * videoWidth;
// Seamless infinite carousel - no jumps, just continuous flow
let adjustedX = newX;
// Keep position within bounds using modulo for seamless loop
while (adjustedX <= -totalWidth) {
adjustedX += totalWidth;
}
while (adjustedX > 0) {
adjustedX -= totalWidth;
}
return adjustedX;
});
}, speedMode === 'fast' ? 10 : 16); // Slower intervals for smoother animation
};
// Initialize with first video on the left side
useEffect(() => {
if (category.videos.length > 0) {
// Start in middle copy (segunda copia) so loop works in both directions
const totalWidth = category.videos.length * videoWidth;
setTranslateX(-totalWidth);
}
}, [category.videos.length]);
// Always show both buttons
useEffect(() => {
setShowLeftButton(true);
setShowRightButton(true);
}, []);
const stopAutoScroll = () => {
setIsScrolling(false);
if (scrollIntervalRef.current) {
clearInterval(scrollIntervalRef.current);
scrollIntervalRef.current = null;
}
};
return (
<div className="relative group mb-8">
<h2 className="text-2xl font-bold text-bunny-light mb-6 px-4">
{category.title}
</h2>
<div className="relative">
{/* Left scroll button - positioned at video thumbnail center */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// If already scrolling, just toggle speed, otherwise do single scroll
if (isScrolling) {
toggleSpeed('left');
} else {
scroll('left');
}
}}
onMouseEnter={(e) => {
e.stopPropagation();
startAutoScroll('left');
}}
onMouseLeave={(e) => {
e.stopPropagation();
stopAutoScroll();
}}
className="flex absolute left-2 top-[45%] -translate-y-1/2 w-12 h-12 z-30 bg-black/80 hover:bg-black/95 rounded-full items-center justify-center transition-all duration-300 cursor-pointer border border-white/30 shadow-lg opacity-0 group-hover:opacity-100 hover:!opacity-100"
data-testid="button-scroll-left"
>
<ChevronLeft className="w-6 h-6 text-white" />
</button>
{/* Right scroll button - positioned at video thumbnail center */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// If already scrolling, just toggle speed, otherwise do single scroll
if (isScrolling) {
toggleSpeed('right');
} else {
scroll('right');
}
}}
onMouseEnter={(e) => {
e.stopPropagation();
startAutoScroll('right');
}}
onMouseLeave={(e) => {
e.stopPropagation();
stopAutoScroll();
}}
className="flex absolute right-2 top-[45%] -translate-y-1/2 w-12 h-12 z-30 bg-black/80 hover:bg-black/95 rounded-full items-center justify-center transition-all duration-300 cursor-pointer border border-white/30 shadow-lg opacity-0 group-hover:opacity-100 hover:!opacity-100"
data-testid="button-scroll-right"
>
<ChevronRight className="w-6 h-6 text-white" />
</button>
{/* Mobile scroll buttons */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
scroll('left');
}}
onTouchStart={(e) => {
e.stopPropagation();
startAutoScroll('left');
}}
onTouchEnd={(e) => {
e.stopPropagation();
stopAutoScroll();
}}
className="md:hidden absolute left-1 top-[45%] -translate-y-1/2 w-10 h-10 z-40 bg-black/80 rounded-full flex items-center justify-center border border-white/30 shadow-lg"
data-testid="button-mobile-scroll-left"
>
<ChevronLeft className="w-5 h-5 text-white" />
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
scroll('right');
}}
onTouchStart={(e) => {
e.stopPropagation();
startAutoScroll('right');
}}
onTouchEnd={(e) => {
e.stopPropagation();
stopAutoScroll();
}}
className="md:hidden absolute right-1 top-[45%] -translate-y-1/2 w-10 h-10 z-40 bg-black/80 rounded-full flex items-center justify-center border border-white/30 shadow-lg"
data-testid="button-mobile-scroll-right"
>
<ChevronRight className="w-5 h-5 text-white" />
</button>
{/* Continuous flowing carousel - videos flow across entire width */}
<div className="overflow-hidden">
<div
className="flex space-x-2 md:space-x-3 pb-4 px-4 md:px-0"
style={{
transform: `translateX(${translateX}px)`,
willChange: 'transform',
transition: isScrolling ? 'none' : 'transform 0.3s ease'
}}
>
{/* Triple the videos for seamless infinite flow */}
{[...category.videos, ...category.videos, ...category.videos].map((video, index) => {
const actualIndex = index % category.videos.length;
return (
<div key={`${video.id}-${Math.floor(index / category.videos.length)}-${actualIndex}`} className="flex-shrink-0 w-28 md:w-52 relative group">
{/* Top 10 Number overlay for first category */}
{category.title.includes("Top 10") && (
<div className="absolute top-1 left-1 z-30 text-white font-black text-3xl md:text-5xl drop-shadow-2xl pointer-events-none"
style={{
textShadow: '4px 4px 8px rgba(0,0,0,0.8), -2px -2px 4px rgba(0,0,0,0.6)',
WebkitTextStroke: '2px rgba(0,0,0,0.8)',
transform: 'none',
transition: 'none'
}}>
{actualIndex + 1}
</div>
)}
<VideoCard
video={video}
onClick={onVideoClick}
className="w-full hover:scale-105 hover:z-10 transition-all duration-300 group-hover:shadow-xl rounded-md overflow-hidden"
/>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}