import { useEffect, useRef, useState } from "react"; import { X, Share2, Edit3, Play, Pause, Volume2, VolumeX, Maximize } from "lucide-react"; import { type Video } from "@shared/schema"; import { Button } from "@/components/ui/button"; import { apiRequest } from "@/lib/queryClient"; import Hls from "hls.js"; import { FacebookIcon, TwitterIcon, WhatsappIcon } from "react-share"; import QualityIndicator from "./quality-indicator"; import VASTPlayer from "./vast-player"; // HLS.js types for video streaming interface VideoModalProps { video: Video | null; isOpen: boolean; onClose: () => void; enableAds?: 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 VideoModal({ video, isOpen, onClose, enableAds = true }: VideoModalProps) { const [useVASTPlayer, setUseVASTPlayer] = useState(true); const videoRef = useRef(null); const hlsRef = useRef(null); const [showShareMenu, setShowShareMenu] = useState(false); const [videoThumbnail, setVideoThumbnail] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [isMuted, setIsMuted] = useState(false); const [showControls, setShowControls] = useState(true); const [controlsTimeout, setControlsTimeout] = useState(null); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [volume, setVolume] = useState(1); const [hoverTime, setHoverTime] = useState(-1); // All hooks must be declared before any conditional returns useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && isOpen) { onClose(); } }; if (isOpen) { document.addEventListener("keydown", handleEscape); document.body.style.overflow = "hidden"; } else { document.body.style.overflow = ""; } return () => { document.removeEventListener("keydown", handleEscape); document.body.style.overflow = ""; }; }, [isOpen, onClose]); // Switch to VAST player for monetization if (isOpen && video && useVASTPlayer && enableAds) { return ; } // Initialize HLS when video is available useEffect(() => { if (isOpen && video && videoRef.current) { const videoElement = videoRef.current; // Clean up previous HLS instance if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } const videoUrl = video.videoUrl; console.log('Loading video with HLS.js:', videoUrl); // Check if the video URL is HLS (.m3u8) if (videoUrl.includes('.m3u8')) { if (Hls.isSupported()) { // Use HLS.js for browsers that don't support HLS natively const hls = new Hls({ debug: false, enableWorker: false, lowLatencyMode: false, // Adaptive bitrate settings for optimal streaming startLevel: -1, // Auto-select starting quality based on bandwidth capLevelToPlayerSize: true, // Limit quality to actual player size maxLoadingDelay: 4, maxBufferLength: 30, // Keep 30 seconds buffered maxBufferSize: 60 * 1000 * 1000, // 60MB buffer maxBufferHole: 0.5, // Network adaptive settings abrEwmaFastLive: 3, abrEwmaSlowLive: 9, abrEwmaFastVoD: 3, abrEwmaSlowVoD: 9, abrMaxWithRealBitrate: false, abrBandWidthFactor: 0.95, // Conservative bandwidth usage abrBandWidthUpFactor: 0.7, // Slower quality upgrades // Fragment loading settings fragLoadingTimeOut: 20000, manifestLoadingTimeOut: 10000, levelLoadingTimeOut: 10000, // Start with lower quality for faster initial load testBandwidth: false }); hls.loadSource(videoUrl); hls.attachMedia(videoElement); hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => { console.log('HLS manifest loaded - Available qualities:', data.levels.map(l => `${l.height}p @ ${Math.round(l.bitrate/1000)}kbps`)); // Log bitrate analysis console.log('BITRATE ANALIZA:'); data.levels.forEach((level, index) => { console.log(`Nivo ${index}: ${level.width}x${level.height} @ ${Math.round(level.bitrate/1000)}kbps`); }); // Set initial quality based on connection const connection = (navigator as any).connection; if (connection) { const effectiveType = connection.effectiveType; const downlink = connection.downlink; // Mbps console.log(`Omrežje: ${effectiveType}, hitrost: ${downlink} Mbps`); // More aggressive quality selection for slow connections if (effectiveType === 'slow-2g' || effectiveType === '2g' || downlink < 1) { hls.startLevel = 0; // Lowest quality console.log('Nastavljam najnižjo kakovost zaradi počasne povezave'); } else if (effectiveType === '3g' || downlink < 3) { hls.startLevel = Math.min(1, data.levels.length - 1); console.log('Nastavljam nizko kakovost zaradi 3G povezave'); } } }); // Quality level monitoring with detailed stats hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => { const level = hls.levels[data.level]; console.log(`PREKLOPIL KAKOVOST: ${level.height}p @ ${Math.round(level.bitrate/1000)}kbps`); console.log('Razlog preklopa: adaptivni algoritem na podlagi omrežne hitrosti'); }); // Fragment loading stats - fixed error handling hls.on(Hls.Events.FRAG_LOADED, (event, data) => { try { if (data.frag && data.frag.stats) { const stats = data.frag.stats; const loadTime = stats.loading.end - stats.loading.start; const speed = (stats.total * 8) / loadTime; // bits per ms = kbps console.log(`Fragment naložen v ${loadTime}ms, hitrost: ${Math.round(speed)} kbps`); } } catch (error) { // Ignore stats errors, they don't affect playback } }); // Buffer monitoring for dynamic adjustment hls.on(Hls.Events.BUFFER_APPENDING, () => { const buffered = videoElement.buffered; if (buffered.length > 0) { const bufferLevel = buffered.end(buffered.length - 1) - videoElement.currentTime; if (bufferLevel < 2) { console.log('Nizek buffer zaznan, lahko zmanjšam kakovost'); } } }); // Network error handling with retries hls.on(Hls.Events.ERROR, (event, data) => { console.error('HLS napaka:', data); if (data.fatal) { switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: console.log('Omrežna napaka, poskušam obnoviti...'); // Try to downgrade quality first if (hls.currentLevel > 0) { hls.currentLevel = hls.currentLevel - 1; console.log('Zmanjšujem kakovost zaradi omrežnih težav'); } hls.startLoad(); break; case Hls.ErrorTypes.MEDIA_ERROR: console.log('Medijska napaka, poskušam obnoviti...'); hls.recoverMediaError(); break; default: console.log('Kritična napaka, uničujem HLS instanco...'); hls.destroy(); break; } } }); hlsRef.current = hls; } else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) { // For Safari that supports HLS natively videoElement.src = videoUrl; console.log('Using native HLS support'); } else { console.error('HLS is not supported in this browser'); } } else { // For regular MP4 videos videoElement.src = videoUrl; console.log('Using native video support for MP4'); } // Video event listeners videoElement.addEventListener('loadeddata', () => { console.log('Video data loaded, capturing thumbnail'); setTimeout(() => captureVideoThumbnail(), 1000); }); videoElement.addEventListener('canplay', () => { console.log('Video can play, capturing thumbnail'); captureVideoThumbnail(); }); videoElement.addEventListener('play', () => setIsPlaying(true)); videoElement.addEventListener('pause', () => setIsPlaying(false)); videoElement.addEventListener('volumechange', () => { setIsMuted(videoElement.muted); setVolume(videoElement.volume); }); videoElement.addEventListener('timeupdate', () => setCurrentTime(videoElement.currentTime)); videoElement.addEventListener('loadedmetadata', () => setDuration(videoElement.duration)); videoElement.addEventListener('durationchange', () => setDuration(videoElement.duration)); } // Cleanup when modal closes return () => { if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } }; }, [isOpen, video]); // Function to capture video thumbnail const captureVideoThumbnail = () => { try { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const videoElement = videoRef.current; if (videoElement && ctx && videoElement.videoWidth > 0) { canvas.width = videoElement.videoWidth; canvas.height = videoElement.videoHeight; ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height); const thumbnailUrl = canvas.toDataURL('image/jpeg', 0.8); setVideoThumbnail(thumbnailUrl); console.log('Video thumbnail captured successfully', canvas.width, 'x', canvas.height); } else { console.log('Video element not ready for thumbnail capture'); // Retry after a delay setTimeout(() => captureVideoThumbnail(), 1000); } } catch (error) { console.log('Failed to capture video thumbnail:', error); } }; const handleVideoPlay = async () => { if (video) { try { await apiRequest("POST", `/api/videos/${video.id}/view`); } catch (error) { console.error("Failed to track video view:", error); } } }; const togglePlay = () => { if (videoRef.current) { if (isPlaying) { videoRef.current.pause(); } else { videoRef.current.play(); handleVideoPlay(); } } }; const toggleMute = () => { if (videoRef.current) { videoRef.current.muted = !videoRef.current.muted; } }; const toggleFullscreen = () => { if (videoRef.current) { if (document.fullscreenElement) { document.exitFullscreen(); } else { videoRef.current.requestFullscreen(); } } }; const showControlsTemporarily = () => { setShowControls(true); if (controlsTimeout) { clearTimeout(controlsTimeout); } const timeout = setTimeout(() => { setShowControls(false); }, 3000); setControlsTimeout(timeout); }; useEffect(() => { return () => { if (controlsTimeout) { clearTimeout(controlsTimeout); } }; }, [controlsTimeout]); const getShareUrl = () => { if (!video?.id) return window.location.origin; return `${window.location.origin}?video=${video.id}`; }; const handleProgressClick = (e: React.MouseEvent) => { if (videoRef.current && duration > 0) { const rect = e.currentTarget.getBoundingClientRect(); const clickX = e.clientX - rect.left; const newTime = (clickX / rect.width) * duration; videoRef.current.currentTime = newTime; setCurrentTime(newTime); } }; const handleProgressHover = (e: React.MouseEvent) => { if (duration > 0) { const rect = e.currentTarget.getBoundingClientRect(); const hoverX = e.clientX - rect.left; const time = (hoverX / rect.width) * duration; setHoverTime(Math.max(0, Math.min(duration, time))); } }; const handleVolumeChange = (e: React.ChangeEvent) => { const newVolume = parseFloat(e.target.value); if (videoRef.current) { videoRef.current.volume = newVolume; setVolume(newVolume); setIsMuted(newVolume === 0); } }; const formatTime = (time: number): string => { const minutes = Math.floor(time / 60); const seconds = Math.floor(time % 60); return `${minutes}:${seconds.toString().padStart(2, '0')}`; }; const copyToClipboard = async () => { try { await navigator.clipboard.writeText(getShareUrl()); // Simple notification instead of alert const notification = document.createElement('div'); notification.textContent = 'Link copied!'; notification.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg z-50 transition-opacity duration-300'; document.body.appendChild(notification); setTimeout(() => { notification.style.opacity = '0'; setTimeout(() => document.body.removeChild(notification), 300); }, 2000); setShowShareMenu(false); } catch (error) { console.error('Failed to copy link:', error); // Fallback for older browsers const textArea = document.createElement('textarea'); textArea.value = getShareUrl(); document.body.appendChild(textArea); textArea.select(); document.execCommand('copy'); document.body.removeChild(textArea); setShowShareMenu(false); } }; const handleBackdropClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose(); } // Close share menu when clicking outside setShowShareMenu(false); }; if (!isOpen || !video) return null; return (
setShowControls(true)} onMouseLeave={() => setShowControls(false)} > {/* Central Play/Pause Button */} {showControls && (
)} {/* Bottom Control Bar */} {showControls && (
{/* Progress Bar */}
setHoverTime(-1)} data-testid="progress-bar" >
0 ? (currentTime / duration) * 100 : 0}%` }} /> {/* Time tooltip on hover */} {hoverTime >= 0 && (
{formatTime(hoverTime)}
)}
{/* Play/Pause Button */} {/* Volume Control */}
{/* Time Display */}
{formatTime(currentTime)} / {formatTime(duration)}
{/* Share Button */}
{/* Share Menu */} {showShareMenu && (
Share Video
{ window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(getShareUrl())}`, '_blank', 'width=600,height=400'); setShowShareMenu(false); }} className="flex items-center gap-2 p-2 w-full hover:bg-gray-100 dark:hover:bg-gray-700 rounded cursor-pointer" > Facebook
{ window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(getShareUrl())}&text=${encodeURIComponent(`Watch "${video.title}" on go4.video`)}`, '_blank', 'width=600,height=400'); setShowShareMenu(false); }} className="flex items-center gap-2 p-2 w-full hover:bg-gray-100 dark:hover:bg-gray-700 rounded cursor-pointer" > Twitter
{ window.open(`https://wa.me/?text=${encodeURIComponent(`Watch "${video.title}" on go4.video: ${getShareUrl()}`)}`, '_blank'); setShowShareMenu(false); }} className="flex items-center gap-2 p-2 w-full hover:bg-gray-100 dark:hover:bg-gray-700 rounded cursor-pointer" > WhatsApp
)}
{/* Fullscreen Button */}
)} {/* Quality Indicator - only show on hover */} {hlsRef.current && showControls && ( )}
{/* Video info - only show when not playing or when controls are visible */} {(!isPlaying || showControls) && (

{video.title}

{formatViews(video.views)} {formatDate(video.createdAt)} {formatDuration(video.duration)}
{video.description && (

{video.description}

)}
)}
); }