Restored to '7fd7127003398a269f1f765208c8baced728aafb'
Replit-Restored-To: 7fd7127003
1
.replit
@ -40,3 +40,4 @@ args = "npm run dev"
|
||||
waitForPort = 5000
|
||||
|
||||
[agent]
|
||||
integrations = ["javascript_log_in_with_replit==1.0.0", "javascript_database==1.0.0", "javascript_object_storage==1.0.0"]
|
||||
|
||||
|
Before Width: | Height: | Size: 7.5 MiB |
|
Before Width: | Height: | Size: 741 KiB |
|
Before Width: | Height: | Size: 618 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 600 KiB |
|
Before Width: | Height: | Size: 629 KiB |
|
Before Width: | Height: | Size: 529 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 177 KiB |
@ -26,19 +26,6 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#3b82f6" />
|
||||
|
||||
<!-- Oswald Google Font z improved loading -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Oswald:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* Fallback font za desktop če se Oswald ne naloži */
|
||||
@font-face {
|
||||
font-family: 'Oswald-fallback';
|
||||
src: local('Arial Black'), local('Helvetica');
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -116,7 +116,7 @@ export default function AdSettings({ isOpen, onClose }: AdSettingsProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4" style={{ zIndex: 2147483647, position: 'fixed' }}>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-900 p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@ -168,19 +168,9 @@ export default function BunnyVideoModal({ video, isOpen, onClose, onEdit, videos
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center"
|
||||
style={{
|
||||
zIndex: 2147483647,
|
||||
backgroundColor: '#1f2937',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '100vw',
|
||||
height: '100vh'
|
||||
}}
|
||||
className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50"
|
||||
onClick={handleBackdropClick}
|
||||
style={{ backgroundColor: '#1f2937' }}
|
||||
>
|
||||
<div className="relative w-full h-full max-w-7xl mx-auto p-4 flex flex-col">
|
||||
{/* Header with close button */}
|
||||
@ -275,7 +265,7 @@ export default function BunnyVideoModal({ video, isOpen, onClose, onEdit, videos
|
||||
className="absolute inset-0 w-full h-full"
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
onLoad={handleVideoPlay}
|
||||
title={video.title}
|
||||
/>
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef } 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";
|
||||
import SimpleCarousel from "./simple-carousel";
|
||||
|
||||
interface VideoCategory {
|
||||
title: string;
|
||||
@ -106,9 +105,9 @@ export default function NetflixGrid({ videos, isLoading }: NetflixGridProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-12 relative">
|
||||
<div className="space-y-12">
|
||||
{categories.map((category, categoryIndex) => (
|
||||
<SimpleCarousel
|
||||
<CategoryRow
|
||||
key={categoryIndex}
|
||||
category={category}
|
||||
onVideoClick={handleVideoClick}
|
||||
@ -132,88 +131,23 @@ interface CategoryRowProps {
|
||||
onVideoClick: (video: Video) => void;
|
||||
}
|
||||
|
||||
function CategoryRowOLD_BROKEN({ category, onVideoClick }: CategoryRowProps) {
|
||||
function CategoryRow({ category, onVideoClick }: CategoryRowProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const scrollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftButton, setShowLeftButton] = useState(false);
|
||||
const [showRightButton, setShowRightButton] = useState(true);
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
const [speedMode, setSpeedMode] = useState<'normal' | 'fast'>('normal');
|
||||
|
||||
const scroll = (direction: 'left' | 'right') => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const scrollAmount = direction === 'right' ? 300 : -300;
|
||||
scrollContainerRef.current.scrollBy({
|
||||
left: scrollAmount,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
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 = 16; // Fixed interval - speed controlled by pixel movement only
|
||||
|
||||
scrollIntervalRef.current = setInterval(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
if (scrollRef.current) {
|
||||
// Responsive scroll amount - wider cards on desktop
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const scrollAmount = isMobile ? 240 : 336; // Mobile: 224px + gap, Desktop: 320px + gap
|
||||
const currentScroll = scrollRef.current.scrollLeft;
|
||||
const targetScroll = direction === 'left'
|
||||
? currentScroll - scrollAmount
|
||||
: currentScroll + scrollAmount;
|
||||
|
||||
const currentSpeed = newSpeed === 'fast' ? 5 : 2;
|
||||
const scrollAmount = direction === 'right' ? currentSpeed : -currentSpeed;
|
||||
scrollContainerRef.current.scrollBy({
|
||||
left: scrollAmount,
|
||||
behavior: 'auto'
|
||||
});
|
||||
}, 16);
|
||||
}
|
||||
};
|
||||
|
||||
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(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const baseSpeed = 3; // Faster speed on hover
|
||||
const scrollAmount = direction === 'right' ? baseSpeed : -baseSpeed;
|
||||
scrollContainerRef.current.scrollBy({
|
||||
left: scrollAmount,
|
||||
behavior: 'auto'
|
||||
scrollRef.current.scrollTo({
|
||||
left: targetScroll,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}, 16);
|
||||
};
|
||||
|
||||
// Initialize with first video on the left side
|
||||
useEffect(() => {
|
||||
// No need to set initial position for scroll - browser handles it
|
||||
}, [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;
|
||||
}
|
||||
};
|
||||
|
||||
@ -223,125 +157,50 @@ function CategoryRowOLD_BROKEN({ category, onVideoClick }: CategoryRowProps) {
|
||||
{category.title}
|
||||
</h2>
|
||||
|
||||
<div className="relative">
|
||||
{/* Left scroll button - positioned at video thumbnail center */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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-80 hover:!opacity-100"
|
||||
data-testid="button-scroll-left"
|
||||
<div className="relative px-16">
|
||||
{/* Left scroll button */}
|
||||
<Button
|
||||
onClick={() => scroll('left')}
|
||||
className="absolute -left-2 top-1/2 -translate-y-1/2 z-30 bg-black/80 hover:bg-black/95 text-white border-none w-12 h-12 rounded-full opacity-0 group-hover:opacity-100 transition-all duration-300 flex items-center justify-center shadow-xl"
|
||||
size="sm"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
{/* Right scroll button - positioned at video thumbnail center */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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-80 hover:!opacity-100"
|
||||
data-testid="button-scroll-right"
|
||||
{/* Right scroll button */}
|
||||
<Button
|
||||
onClick={() => scroll('right')}
|
||||
className="absolute -right-2 top-1/2 -translate-y-1/2 z-30 bg-black/80 hover:bg-black/95 text-white border-none w-12 h-12 rounded-full opacity-0 group-hover:opacity-100 transition-all duration-300 flex items-center justify-center shadow-xl"
|
||||
size="sm"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</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>
|
||||
|
||||
{/* Simple horizontal scroll carousel */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="overflow-x-auto scrollbar-hide"
|
||||
{/* Scrollable video row */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex space-x-4 overflow-x-auto scrollbar-hide pb-4"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<div className="flex space-x-4 pb-4 w-max">
|
||||
{/* ULTRA SIMPLE - just many copies */}
|
||||
{Array.from({ length: 50 }).map((_, copyIndex) =>
|
||||
category.videos.map((video, videoIndex) => (
|
||||
<div key={`${video.id}-${copyIndex}-${videoIndex}`} 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'
|
||||
}}>
|
||||
{videoIndex + 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"
|
||||
/>
|
||||
{/* Simple debug number */}
|
||||
<div className="absolute bottom-0 right-0 bg-green-500 text-white text-xs px-1 rounded">
|
||||
{videoIndex + 1}
|
||||
</div>
|
||||
{category.videos.map((video, index) => (
|
||||
<div key={video.id} className="flex-shrink-0 w-56 md:w-80 relative group">
|
||||
{/* Top 10 Number overlay for first category */}
|
||||
{category.title.includes("Top 10") && index < 10 && (
|
||||
<div className="absolute top-2 left-2 z-20 text-white font-black text-5xl md:text-7xl drop-shadow-2xl"
|
||||
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)'
|
||||
}}>
|
||||
{index + 1}
|
||||
</div>
|
||||
))
|
||||
).flat()}
|
||||
</div>
|
||||
)}
|
||||
<VideoCard
|
||||
video={video}
|
||||
onClick={onVideoClick}
|
||||
className="w-full hover:scale-110 hover:z-10 transition-all duration-500 group-hover:shadow-2xl rounded-lg overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -59,7 +59,7 @@ export default function SearchHeader({
|
||||
<div className="w-9 h-9 gradient-primary rounded-lg flex items-center justify-center shadow-lg">
|
||||
<div className="w-0 h-0 border-l-[10px] border-l-white border-y-[7px] border-y-transparent ml-1"></div>
|
||||
</div>
|
||||
<h1 className="oswald-text text-2xl text-white">go4.video</h1>
|
||||
<h1 className="text-2xl font-bold text-white tracking-wide">go4.video</h1>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,128 +0,0 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { type Video } from "@shared/schema";
|
||||
import VideoCard from "./video-card";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface SimpleCarouselProps {
|
||||
category: {
|
||||
title: string;
|
||||
videos: Video[];
|
||||
};
|
||||
onVideoClick: (video: Video) => void;
|
||||
}
|
||||
|
||||
export default function SimpleCarousel({ category, onVideoClick }: SimpleCarouselProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
const [currentDirection, setCurrentDirection] = useState<'left' | 'right' | null>(null);
|
||||
const [speed, setSpeed] = useState<'normal' | 'fast'>('normal');
|
||||
|
||||
const scroll = (direction: 'left' | 'right') => {
|
||||
// If already scrolling in same direction, toggle speed
|
||||
if (isScrolling && currentDirection === direction) {
|
||||
const newSpeed = speed === 'normal' ? 'fast' : 'normal';
|
||||
setSpeed(newSpeed);
|
||||
|
||||
// Restart with new speed immediately
|
||||
if (scrollIntervalRef.current) {
|
||||
clearInterval(scrollIntervalRef.current);
|
||||
}
|
||||
|
||||
const speedValue = newSpeed === 'fast' ? 1.8 : 0.8;
|
||||
scrollIntervalRef.current = setInterval(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const scrollAmount = direction === 'right' ? speedValue : -speedValue;
|
||||
scrollContainerRef.current.scrollBy({
|
||||
left: scrollAmount,
|
||||
behavior: 'auto'
|
||||
});
|
||||
}, 8);
|
||||
} else {
|
||||
// If not scrolling or different direction, start scrolling
|
||||
startAutoScroll(direction);
|
||||
}
|
||||
};
|
||||
|
||||
const startAutoScroll = (direction: 'left' | 'right') => {
|
||||
if (scrollIntervalRef.current) {
|
||||
clearInterval(scrollIntervalRef.current);
|
||||
}
|
||||
|
||||
setIsScrolling(true);
|
||||
setCurrentDirection(direction);
|
||||
setSpeed('normal'); // Reset to normal speed when starting
|
||||
|
||||
const speedValue = 0.8; // Always start with normal speed
|
||||
scrollIntervalRef.current = setInterval(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const scrollAmount = direction === 'right' ? speedValue : -speedValue;
|
||||
scrollContainerRef.current.scrollBy({
|
||||
left: scrollAmount,
|
||||
behavior: 'auto'
|
||||
});
|
||||
}, 8);
|
||||
};
|
||||
|
||||
const stopAutoScroll = () => {
|
||||
if (scrollIntervalRef.current) {
|
||||
clearInterval(scrollIntervalRef.current);
|
||||
scrollIntervalRef.current = null;
|
||||
}
|
||||
setIsScrolling(false);
|
||||
setCurrentDirection(null);
|
||||
setSpeed('normal');
|
||||
};
|
||||
|
||||
// Initialize scroll position in the middle so we can scroll both ways
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current && category.videos.length > 0) {
|
||||
// Wait for content to load, then scroll to middle
|
||||
setTimeout(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
const containerWidth = scrollContainerRef.current.scrollWidth;
|
||||
const viewportWidth = scrollContainerRef.current.clientWidth;
|
||||
const middlePosition = (containerWidth - viewportWidth) / 2;
|
||||
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: middlePosition,
|
||||
behavior: 'auto'
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [category.videos.length]);
|
||||
|
||||
return (
|
||||
<div className="relative group mb-12">
|
||||
<h2 className="oswald-text text-2xl text-bunny-light mb-6 px-4">
|
||||
{category.title}
|
||||
</h2>
|
||||
|
||||
{/* Container z flex wrap */}
|
||||
<div className="container flex flex-wrap justify-center gap-2.5 p-2.5 md:flex-row flex-col items-center">
|
||||
{category.videos.map((video, videoIndex) => (
|
||||
<div key={video.id} className="relative">
|
||||
{/* 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-xl md:text-2xl 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: '1px rgba(0,0,0,0.8)',
|
||||
}}>
|
||||
{videoIndex + 1}
|
||||
</div>
|
||||
)}
|
||||
<VideoCard
|
||||
video={video}
|
||||
onClick={onVideoClick}
|
||||
className="poster w-[150px] md:w-[150px] sm:w-[120px] h-auto hover:scale-105 transition-all duration-300 hover:shadow-2xl rounded-md overflow-hidden relative"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -270,7 +270,7 @@ export default function VASTPlayer({ video, onClose, vastTagUrl, enableAds = tru
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center" style={{ zIndex: 2147483647, position: 'fixed' }}>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50">
|
||||
<div className="relative w-full max-w-6xl mx-4">
|
||||
{/* Close button */}
|
||||
<Button
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import { Play, Plus, ThumbsUp, ChevronDown } from "lucide-react";
|
||||
import { Play } 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;
|
||||
@ -48,43 +46,10 @@ export default function VideoCard({ video, onClick, className = "" }: VideoCardP
|
||||
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) {
|
||||
if (isHovered) {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setShowPreview(true);
|
||||
}, 800); // Start preview after 800ms hover
|
||||
@ -102,89 +67,23 @@ export default function VideoCard({ video, onClick, className = "" }: VideoCardP
|
||||
};
|
||||
}, [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-300 ease-out hover:scale-110 ${className}`}
|
||||
style={{
|
||||
transformStyle: 'preserve-3d',
|
||||
transition: 'transform 0.3s ease',
|
||||
willChange: 'transform',
|
||||
zIndex: isHovered ? 2147483647 : 1,
|
||||
position: 'relative'
|
||||
}}
|
||||
className={`video-card transition-transform duration-200 hover:scale-[1.02] p-3 ${className}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Video preview container */}
|
||||
<div
|
||||
className="relative overflow-hidden cursor-pointer group aspect-[16/9]"
|
||||
className="relative gradient-card rounded-xl overflow-hidden mb-4 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'}`}
|
||||
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'
|
||||
@ -212,34 +111,63 @@ export default function VideoCard({ video, onClick, className = "" }: VideoCardP
|
||||
{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
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="none"
|
||||
/>
|
||||
onLoadStart={() => console.log('Preview loading for:', video.title)}
|
||||
onError={(e) => console.log('Preview failed for:', video.title)}
|
||||
>
|
||||
{/* Try MP4 source first for faster loading */}
|
||||
{video.mp4Url && (
|
||||
<source src={video.mp4Url} type="video/mp4" />
|
||||
)}
|
||||
{/* Fallback to HLS if MP4 fails */}
|
||||
{video.hlsUrl && (
|
||||
<source src={video.hlsUrl} type="application/x-mpegURL" />
|
||||
)}
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title overlay at bottom */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent p-3">
|
||||
<h3
|
||||
className="oswald-text text-white text-sm md:text-base line-clamp-2 hover:text-bunny-blue transition-colors duration-300 cursor-pointer"
|
||||
onClick={() => onClick?.(video)}
|
||||
data-testid={`text-title-${video.id}`}
|
||||
>
|
||||
{video.title}
|
||||
</h3>
|
||||
{/* 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>
|
||||
|
||||
{/* Play button overlay - hidden during preview */}
|
||||
{!showPreview && (
|
||||
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/40 transition-all duration-300 flex items-center justify-center">
|
||||
<div className="bg-white/20 backdrop-blur-sm rounded-full w-16 h-16 flex items-center justify-center group-hover:bg-white/30 group-hover:scale-110 transition-all duration-300">
|
||||
<div className="w-0 h-0 border-l-[12px] border-l-white border-y-[8px] border-y-transparent ml-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3
|
||||
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}`}
|
||||
>
|
||||
{video.title}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-3 text-sm text-bunny-muted">
|
||||
<span data-testid={`text-views-${video.id}`}>
|
||||
{formatViews(video.views)}
|
||||
</span>
|
||||
<span data-testid={`text-date-${video.id}`}>
|
||||
{formatDate(video.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -437,8 +437,7 @@ export default function VideoModal({ video, isOpen, onClose, enableAds = true }:
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
style={{ zIndex: 2147483647, position: 'fixed' }}
|
||||
className="fixed inset-0 bg-black/90 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onClick={handleBackdropClick}
|
||||
data-testid="modal-video"
|
||||
>
|
||||
|
||||
@ -2,32 +2,6 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Hide scrollbars only on mobile devices */
|
||||
@media (max-width: 768px) {
|
||||
* {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: hsl(222, 84%, 4.9%);
|
||||
--foreground: hsl(210, 40%, 98%);
|
||||
@ -189,27 +163,6 @@
|
||||
.animation-delay-150 {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
/* Force modal z-index above everything */
|
||||
.modal-overlay {
|
||||
z-index: 2147483647 !important;
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
}
|
||||
|
||||
/* Keep video cards low */
|
||||
.video-card {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
.video-card:hover {
|
||||
z-index: 5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Video edit modal styles */
|
||||
@ -380,82 +333,4 @@ input[data-testid*="search"]::placeholder {
|
||||
user-select: none;
|
||||
opacity: 0.08;
|
||||
filter: blur(0.5px);
|
||||
}
|
||||
|
||||
/* Individual video hover effect for Top 10 numbers */
|
||||
.individual-video-hover:hover .individual-video-hover\:opacity-0 {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Maximum possible z-index for video card hover */
|
||||
.video-card:hover {
|
||||
z-index: 2147483647 !important;
|
||||
}
|
||||
|
||||
/* Oswald font povsod z fallback za desktop */
|
||||
.oswald-text {
|
||||
font-family: 'Oswald', 'Arial Black', 'Helvetica', sans-serif;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Container flex wrap styles */
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 10px; /* Razmik med slikami */
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.poster {
|
||||
width: 150px; /* Velikost slike */
|
||||
height: auto; /* Ohranja razmerje */
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
flex-direction: column; /* Slike ena pod drugo */
|
||||
align-items: center;
|
||||
}
|
||||
.poster {
|
||||
width: 120px; /* Manjša velikost za mobilne naprave */
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide picture-in-picture button on all video elements */
|
||||
video::-webkit-media-controls-picture-in-picture-button {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
video::-moz-picture-in-picture-button {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/* Hide Video.js picture-in-picture button */
|
||||
.video-js .vjs-picture-in-picture-control {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
/* Hide all picture-in-picture related elements */
|
||||
*[aria-label*="picture"],
|
||||
*[title*="picture"],
|
||||
*[data-title*="picture"],
|
||||
*[class*="picture"],
|
||||
*[class*="pip"],
|
||||
.vjs-picture-in-picture-control,
|
||||
.vjs-pip-button {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
@ -252,7 +252,7 @@ export default function VideoPage() {
|
||||
className="absolute inset-0 w-full h-full"
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
onLoad={handleVideoPlay}
|
||||
title={currentVideo.title}
|
||||
/>
|
||||
|
||||
@ -78,7 +78,7 @@ export default function Home() {
|
||||
currentView={viewMode}
|
||||
/>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 relative overflow-visible netflix-grid-container">
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 relative">
|
||||
|
||||
|
||||
{/* Trikotniki na robovih - ne prekrivajo video kartic */}
|
||||
|
||||
@ -73,11 +73,9 @@ export class BunnyService {
|
||||
|
||||
private bunnyVideoToVideo(bunnyVideo: BunnyVideo | BunnyVideoDetails): Video {
|
||||
// Generate optimized thumbnail URL from Bunny CDN with WebP format for better performance
|
||||
// Add cache busting timestamp to ensure fresh thumbnails
|
||||
const timestamp = Date.now();
|
||||
const thumbnailUrl = bunnyVideo.thumbnailFileName
|
||||
? `https://${this.hostname}/${bunnyVideo.guid}/${bunnyVideo.thumbnailFileName}?width=400&height=225&format=webp&t=${timestamp}`
|
||||
: `https://${this.hostname}/${bunnyVideo.guid}/thumbnail.jpg?width=400&height=225&format=webp&t=${timestamp}`;
|
||||
? `https://${this.hostname}/${bunnyVideo.guid}/${bunnyVideo.thumbnailFileName}?width=400&height=225&format=webp`
|
||||
: `https://${this.hostname}/${bunnyVideo.guid}/thumbnail.jpg?width=400&height=225&format=webp`;
|
||||
|
||||
// Generate signed HLS URL for private video access
|
||||
const hlsUrl = this.generateSignedUrl(bunnyVideo.guid);
|
||||
|
||||