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
686 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|