videofolxtv/client/src/components/video-modal.tsx
sebastjanartic 2c46b13de8 Update video progress bar to use orange color scheme
Modify the color palette for video progress bars and associated elements within VideoCard and VideoModal components from blue/purple to orange.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 074b0e4c-6171-43bd-aa98-f9e04623ca14
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/074b0e4c-6171-43bd-aa98-f9e04623ca14/DVZN4Rp
2025-08-30 15:32:07 +00:00

687 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]);
// 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]);
// Cleanup controls timeout - moved before any returns
useEffect(() => {
return () => {
if (controlsTimeout) {
clearTimeout(controlsTimeout);
}
};
}, [controlsTimeout]);
// Switch to VAST player for monetization - after all hooks
if (isOpen && video && useVASTPlayer && enableAds) {
return <VASTPlayer video={video} onClose={onClose} enableAds={enableAds} />;
}
// 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);
};
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-orange-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>
);
}