68a9dc5749
This commit implements full video upload, storage, streaming, and playback functionality
for the PicPeak photo sharing platform, allowing users to upload and view videos alongside
photos in galleries.
Backend Changes:
- Added video processing dependencies (fluent-ffmpeg, @ffmpeg-installer/ffmpeg)
- Created videoProcessor.js service for video metadata extraction and thumbnail generation
- Updated photoProcessor.js to handle both images and videos
- Modified adminPhotos.js to accept video files with 500MB size limit
- Enhanced gallery.js with HTTP range request support for video streaming
- Expanded fileSecurityUtils.js with video MIME types and magic number validation
- Added database migration for video support columns (media_type, duration, codecs, dimensions)
Frontend Changes:
- Updated TypeScript types to include video metadata fields
- Created VideoPlayer.tsx component with custom controls
- Modified PhotoUpload.tsx to accept video files (.mp4, .webm, .mov, .avi)
- Updated UserPhotoUpload.tsx for guest video uploads
- Enhanced PhotoGrid.tsx with video badges and duration display
- Modified PhotoLightbox.tsx to conditionally render VideoPlayer for videos
Database Schema:
- Added media_type column ('image' | 'video')
- Added mime_type, duration, video_codec, audio_codec columns
- Added width and height columns for media dimensions
- Migrated existing photos to media_type 'image'
Features:
- Video thumbnail generation from video frames
- Streaming support with range requests for efficient playback
- Video duration display on thumbnails
- Play button indicators on video items
- Full-featured video player with playback controls
- Support for MP4, WebM, MOV, and AVI formats
233 lines
6.7 KiB
TypeScript
233 lines
6.7 KiB
TypeScript
import React, { useRef, useState, useEffect } from 'react';
|
|
import { Play, Pause, Volume2, VolumeX, Maximize, Minimize } from 'lucide-react';
|
|
|
|
interface VideoPlayerProps {
|
|
src: string;
|
|
poster?: string;
|
|
className?: string;
|
|
autoPlay?: boolean;
|
|
muted?: boolean;
|
|
loop?: boolean;
|
|
controls?: boolean;
|
|
width?: string | number;
|
|
height?: string | number;
|
|
}
|
|
|
|
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|
src,
|
|
poster,
|
|
className = '',
|
|
autoPlay = false,
|
|
muted = false,
|
|
loop = false,
|
|
controls = true,
|
|
width = '100%',
|
|
height = 'auto'
|
|
}) => {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [isMuted, setIsMuted] = useState(muted);
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
const [progress, setProgress] = useState(0);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [showControls, setShowControls] = useState(true);
|
|
const controlsTimeoutRef = useRef<NodeJS.Timeout>();
|
|
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
const handleTimeUpdate = () => {
|
|
setCurrentTime(video.currentTime);
|
|
setProgress((video.currentTime / video.duration) * 100 || 0);
|
|
};
|
|
|
|
const handleLoadedMetadata = () => {
|
|
setDuration(video.duration);
|
|
};
|
|
|
|
const handlePlay = () => setIsPlaying(true);
|
|
const handlePause = () => setIsPlaying(false);
|
|
const handleEnded = () => setIsPlaying(false);
|
|
|
|
video.addEventListener('timeupdate', handleTimeUpdate);
|
|
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
|
video.addEventListener('play', handlePlay);
|
|
video.addEventListener('pause', handlePause);
|
|
video.addEventListener('ended', handleEnded);
|
|
|
|
return () => {
|
|
video.removeEventListener('timeupdate', handleTimeUpdate);
|
|
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
|
video.removeEventListener('play', handlePlay);
|
|
video.removeEventListener('pause', handlePause);
|
|
video.removeEventListener('ended', handleEnded);
|
|
};
|
|
}, []);
|
|
|
|
const togglePlayPause = () => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
if (isPlaying) {
|
|
video.pause();
|
|
} else {
|
|
video.play();
|
|
}
|
|
};
|
|
|
|
const toggleMute = () => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
video.muted = !video.muted;
|
|
setIsMuted(!isMuted);
|
|
};
|
|
|
|
const toggleFullscreen = async () => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
try {
|
|
if (!isFullscreen) {
|
|
if (video.requestFullscreen) {
|
|
await video.requestFullscreen();
|
|
}
|
|
setIsFullscreen(true);
|
|
} else {
|
|
if (document.exitFullscreen) {
|
|
await document.exitFullscreen();
|
|
}
|
|
setIsFullscreen(false);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling fullscreen:', error);
|
|
}
|
|
};
|
|
|
|
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const pos = (e.clientX - rect.left) / rect.width;
|
|
video.currentTime = pos * video.duration;
|
|
};
|
|
|
|
const formatTime = (seconds: number): string => {
|
|
if (!seconds || isNaN(seconds)) return '0:00';
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
const handleMouseMove = () => {
|
|
setShowControls(true);
|
|
if (controlsTimeoutRef.current) {
|
|
clearTimeout(controlsTimeoutRef.current);
|
|
}
|
|
controlsTimeoutRef.current = setTimeout(() => {
|
|
if (isPlaying) {
|
|
setShowControls(false);
|
|
}
|
|
}, 3000);
|
|
};
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (controlsTimeoutRef.current) {
|
|
clearTimeout(controlsTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
className={`relative bg-black rounded-lg overflow-hidden ${className}`}
|
|
style={{ width, height: height === 'auto' ? undefined : height }}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={() => isPlaying && setShowControls(false)}
|
|
>
|
|
<video
|
|
ref={videoRef}
|
|
src={src}
|
|
poster={poster}
|
|
autoPlay={autoPlay}
|
|
muted={muted}
|
|
loop={loop}
|
|
className="w-full h-full object-contain"
|
|
playsInline
|
|
onClick={togglePlayPause}
|
|
/>
|
|
|
|
{controls && (
|
|
<div
|
|
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300 ${
|
|
showControls ? 'opacity-100' : 'opacity-0'
|
|
}`}
|
|
>
|
|
{/* Progress bar */}
|
|
<div
|
|
className="w-full h-1 bg-gray-600 rounded-full cursor-pointer mb-3"
|
|
onClick={handleProgressClick}
|
|
>
|
|
<div
|
|
className="h-full bg-white rounded-full transition-all"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="flex items-center justify-between text-white">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={togglePlayPause}
|
|
className="hover:bg-white/20 p-2 rounded-full transition-colors"
|
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
>
|
|
{isPlaying ? <Pause size={20} /> : <Play size={20} />}
|
|
</button>
|
|
|
|
<button
|
|
onClick={toggleMute}
|
|
className="hover:bg-white/20 p-2 rounded-full transition-colors"
|
|
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
|
>
|
|
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
|
</button>
|
|
|
|
<span className="text-sm">
|
|
{formatTime(currentTime)} / {formatTime(duration)}
|
|
</span>
|
|
</div>
|
|
|
|
<button
|
|
onClick={toggleFullscreen}
|
|
className="hover:bg-white/20 p-2 rounded-full transition-colors"
|
|
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
>
|
|
{isFullscreen ? <Minimize size={20} /> : <Maximize size={20} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Play button overlay when paused */}
|
|
{!isPlaying && showControls && (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<button
|
|
onClick={togglePlayPause}
|
|
className="bg-black/50 hover:bg-black/70 text-white rounded-full p-6 transition-colors"
|
|
aria-label="Play"
|
|
>
|
|
<Play size={48} fill="white" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default VideoPlayer;
|