videofolxtv/client/src/components/video-modal.tsx
sebastjanartic 175001993a Update video modal and styling for better user experience
Reorders VideoModal component logic to ensure hooks are declared before conditional returns and applies updated styling to select and combobox elements in the video edit modal.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: d7424866-83d1-4486-a212-ac12b4c7becf
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/d7424866-83d1-4486-a212-ac12b4c7becf/HTFgzym
2025-08-08 20:13:18 +00:00

686 lines
26 KiB
TypeScript

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<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const [showShareMenu, setShowShareMenu] = useState(false);
const [videoThumbnail, setVideoThumbnail] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [showControls, setShowControls] = useState(true);
const [controlsTimeout, setControlsTimeout] = useState<NodeJS.Timeout | null>(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 <VASTPlayer video={video} onClose={onClose} enableAds={enableAds} />;
}
// 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLInputElement>) => {
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 (
<div
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"
>
<div className="relative w-full max-w-6xl">
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="absolute -top-12 right-0 text-white hover:text-bunny-blue transition-colors z-60"
data-testid="button-close-modal"
>
<X className="text-2xl" />
</Button>
<div className="relative bg-black rounded-lg overflow-hidden">
<div
className="relative flex items-center justify-center bg-black"
onMouseMove={showControlsTemporarily}
onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)}
>
<video
ref={videoRef}
className="w-full h-auto max-h-[80vh] cursor-pointer"
preload="metadata"
controls={false}
onPlay={handleVideoPlay}
data-testid="video-player"
crossOrigin="anonymous"
onClick={togglePlay}
>
Your browser does not support the video tag.
</video>
{/* Central Play/Pause Button */}
{showControls && (
<div className="absolute inset-0 flex items-center justify-center">
<Button
onClick={togglePlay}
variant="ghost"
size="icon"
className={`rounded-full bg-black/50 hover:bg-black/70 text-white border-2 border-white/20 hover:border-white/40 transition-all duration-200 ${
isPlaying ? 'w-16 h-16 opacity-0 hover:opacity-100' : 'w-20 h-20 opacity-100'
}`}
data-testid="button-play-center"
>
{isPlaying ? <Pause className="w-6 h-6" /> : <Play className="w-8 h-8 ml-1" />}
</Button>
</div>
)}
{/* Bottom Control Bar */}
{showControls && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/70 to-transparent p-4">
{/* Progress Bar */}
<div className="mb-3">
<div
className="w-full h-2 bg-white/20 rounded-full cursor-pointer hover:h-3 transition-all duration-200 relative"
onClick={handleProgressClick}
onMouseMove={handleProgressHover}
onMouseLeave={() => setHoverTime(-1)}
data-testid="progress-bar"
>
<div
className="h-full bg-blue-500 rounded-full transition-all duration-200"
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
/>
{/* Time tooltip on hover */}
{hoverTime >= 0 && (
<div
className="absolute -top-8 bg-black/80 text-white text-xs px-2 py-1 rounded pointer-events-none"
style={{ left: `${(hoverTime / duration) * 100}%`, transform: 'translateX(-50%)' }}
>
{formatTime(hoverTime)}
</div>
)}
</div>
</div>
<div className="flex items-center gap-4">
{/* Play/Pause Button */}
<Button
onClick={togglePlay}
variant="ghost"
size="icon"
className="text-white hover:text-bunny-blue transition-colors"
data-testid="button-play-pause"
>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
</Button>
{/* Volume Control */}
<div className="flex items-center gap-2">
<Button
onClick={toggleMute}
variant="ghost"
size="icon"
className="text-white hover:text-bunny-blue transition-colors"
data-testid="button-volume"
>
{isMuted ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
</Button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-16 h-1 bg-white/20 rounded-lg appearance-none cursor-pointer slider"
data-testid="volume-slider"
/>
</div>
{/* Time Display */}
<div className="text-white text-sm font-mono" data-testid="time-display">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
{/* Share Button */}
<div className="relative">
<Button
onClick={() => setShowShareMenu(!showShareMenu)}
variant="ghost"
size="icon"
className="text-white hover:text-bunny-blue transition-colors"
data-testid="button-share"
>
<Share2 className="w-5 h-5" />
</Button>
{/* Share Menu */}
{showShareMenu && (
<div className="absolute bottom-12 right-0 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 z-50 min-w-[200px]">
<div className="flex flex-col gap-2">
<div className="pb-2 border-b border-gray-200 dark:border-gray-600">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">Share Video</span>
</div>
<div
onClick={() => {
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"
>
<FacebookIcon size={20} round />
<span className="text-sm text-gray-900 dark:text-gray-100">Facebook</span>
</div>
<div
onClick={() => {
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"
>
<TwitterIcon size={20} round />
<span className="text-sm text-gray-900 dark:text-gray-100">Twitter</span>
</div>
<div
onClick={() => {
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"
>
<WhatsappIcon size={20} round />
<span className="text-sm text-gray-900 dark:text-gray-100">WhatsApp</span>
</div>
<button
onClick={copyToClipboard}
className="flex items-center gap-2 p-2 w-full hover:bg-gray-100 dark:hover:bg-gray-700 rounded cursor-pointer text-left"
>
<div className="w-5 h-5 bg-gray-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs">📋</span>
</div>
<span className="text-sm text-gray-900 dark:text-gray-100">Copy Link</span>
</button>
</div>
</div>
)}
</div>
{/* Fullscreen Button */}
<Button
onClick={toggleFullscreen}
variant="ghost"
size="icon"
className="text-white hover:text-bunny-blue transition-colors ml-auto"
data-testid="button-fullscreen"
>
<Maximize className="w-5 h-5" />
</Button>
</div>
</div>
)}
{/* Quality Indicator - only show on hover */}
{hlsRef.current && showControls && (
<QualityIndicator
hlsInstance={hlsRef.current}
className="absolute top-4 left-4 transition-opacity duration-300"
/>
)}
</div>
{/* Video info - only show when not playing or when controls are visible */}
{(!isPlaying || showControls) && (
<div className="absolute bottom-16 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6 pointer-events-none transition-opacity duration-300">
<h3
className="text-xl font-semibold mb-2 text-white"
data-testid="text-modal-title"
>
{video.title}
</h3>
<div className="flex items-center space-x-4 text-sm text-gray-300">
<span data-testid="text-modal-views">
{formatViews(video.views)}
</span>
<span data-testid="text-modal-date">
{formatDate(video.createdAt)}
</span>
<span data-testid="text-modal-duration">
{formatDuration(video.duration)}
</span>
</div>
{video.description && (
<p className="mt-3 text-gray-300 text-sm" data-testid="text-modal-description">
{video.description}
</p>
)}
</div>
)}
</div>
</div>
</div>
);
}