393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { X, Play, Pause, Volume2, VolumeX, Maximize, Star, Bookmark } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { createPortal } from 'react-dom';
|
|
|
|
interface Video {
|
|
id: number;
|
|
title: string;
|
|
path: string;
|
|
size: number;
|
|
thumbnail: string;
|
|
type: string;
|
|
bookmark_count: number;
|
|
avg_rating: number;
|
|
star_count: number;
|
|
}
|
|
|
|
interface FileSystemItem {
|
|
name: string;
|
|
path: string;
|
|
isDirectory: boolean;
|
|
size: number;
|
|
thumbnail?: string;
|
|
type?: string;
|
|
id?: number;
|
|
}
|
|
|
|
interface VideoViewerProps {
|
|
video: Video | FileSystemItem;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
showBookmarks?: boolean;
|
|
showRatings?: boolean;
|
|
formatFileSize?: (bytes: number) => string;
|
|
onBookmark?: (videoId: number) => void;
|
|
onUnbookmark?: (videoId: number) => void;
|
|
onRate?: (videoId: number, rating: number) => void;
|
|
}
|
|
|
|
export default function VideoViewer({
|
|
video,
|
|
isOpen,
|
|
onClose,
|
|
showBookmarks = false,
|
|
showRatings = false,
|
|
formatFileSize,
|
|
onBookmark,
|
|
onUnbookmark,
|
|
onRate
|
|
}: VideoViewerProps) {
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [isMuted, setIsMuted] = useState(false);
|
|
const [volume, setVolume] = useState(1);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [showControls, setShowControls] = useState(true);
|
|
const [isBookmarked, setIsBookmarked] = useState(false);
|
|
const [bookmarkCount, setBookmarkCount] = useState(0);
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
|
|
// Update local bookmark state when video changes
|
|
useEffect(() => {
|
|
if (video && 'bookmark_count' in video) {
|
|
setIsBookmarked(video.bookmark_count > 0);
|
|
setBookmarkCount(video.bookmark_count);
|
|
}
|
|
}, [video]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen && videoRef.current && video) {
|
|
videoRef.current.src = `/api/stream/${('id' in video ? video.id : video.id) || ''}`;
|
|
videoRef.current.load();
|
|
|
|
// Auto-play when video is loaded
|
|
videoRef.current.addEventListener('loadeddata', () => {
|
|
if (videoRef.current) {
|
|
videoRef.current.play().then(() => {
|
|
setIsPlaying(true);
|
|
}).catch((error) => {
|
|
console.log('Auto-play prevented by browser:', error);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}, [isOpen, video]);
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (!videoRef.current) return;
|
|
|
|
switch (e.key) {
|
|
case 'Escape':
|
|
e.preventDefault();
|
|
onClose();
|
|
break;
|
|
case 'ArrowRight':
|
|
e.preventDefault();
|
|
videoRef.current.currentTime = Math.min(
|
|
videoRef.current.currentTime + 10,
|
|
videoRef.current.duration || 0
|
|
);
|
|
break;
|
|
case 'ArrowLeft':
|
|
e.preventDefault();
|
|
videoRef.current.currentTime = Math.max(
|
|
videoRef.current.currentTime - 10,
|
|
0
|
|
);
|
|
break;
|
|
case ' ':
|
|
e.preventDefault();
|
|
handlePlayPause();
|
|
break;
|
|
case 'f':
|
|
case 'F':
|
|
e.preventDefault();
|
|
handleFullscreen();
|
|
break;
|
|
case 'm':
|
|
case 'M':
|
|
e.preventDefault();
|
|
handleMute();
|
|
break;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [isOpen, onClose]);
|
|
|
|
const handlePlayPause = () => {
|
|
if (videoRef.current) {
|
|
if (isPlaying) {
|
|
videoRef.current.pause();
|
|
} else {
|
|
videoRef.current.play();
|
|
}
|
|
setIsPlaying(!isPlaying);
|
|
}
|
|
};
|
|
|
|
const handleMute = () => {
|
|
if (videoRef.current) {
|
|
videoRef.current.muted = !isMuted;
|
|
setIsMuted(!isMuted);
|
|
}
|
|
};
|
|
|
|
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const newVolume = parseFloat(e.target.value);
|
|
setVolume(newVolume);
|
|
if (videoRef.current) {
|
|
videoRef.current.volume = newVolume;
|
|
}
|
|
};
|
|
|
|
const handleTimeUpdate = () => {
|
|
if (videoRef.current) {
|
|
setCurrentTime(videoRef.current.currentTime);
|
|
}
|
|
};
|
|
|
|
const handleLoadedMetadata = () => {
|
|
if (videoRef.current) {
|
|
setDuration(videoRef.current.duration);
|
|
}
|
|
};
|
|
|
|
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const newTime = parseFloat(e.target.value);
|
|
if (videoRef.current) {
|
|
videoRef.current.currentTime = newTime;
|
|
setCurrentTime(newTime);
|
|
}
|
|
};
|
|
|
|
const handleFullscreen = () => {
|
|
if (videoRef.current) {
|
|
if (document.fullscreenElement) {
|
|
document.exitFullscreen();
|
|
} else {
|
|
videoRef.current.requestFullscreen();
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleBookmark = () => {
|
|
if (onBookmark && video && 'id' in video && video.id !== undefined) {
|
|
onBookmark(video.id);
|
|
// Update local state immediately
|
|
setIsBookmarked(true);
|
|
setBookmarkCount(prev => prev + 1);
|
|
}
|
|
};
|
|
|
|
const handleUnbookmark = () => {
|
|
if (onUnbookmark && video && 'id' in video && video.id !== undefined) {
|
|
onUnbookmark(video.id);
|
|
// Update local state immediately
|
|
setIsBookmarked(false);
|
|
setBookmarkCount(prev => Math.max(0, prev - 1));
|
|
}
|
|
};
|
|
|
|
const handleRate = (rating: number) => {
|
|
if (onRate && video && 'id' in video && video.id !== undefined) {
|
|
onRate(video.id, rating);
|
|
}
|
|
};
|
|
|
|
const getVideoTitle = () => {
|
|
if (video && 'title' in video) return video.title;
|
|
if (video && 'name' in video) return video.name;
|
|
return 'Video';
|
|
};
|
|
|
|
const getVideoSize = () => {
|
|
if (!video) return '0 Bytes';
|
|
if (formatFileSize) {
|
|
return formatFileSize(video.size);
|
|
}
|
|
// Default format function
|
|
const bytes = video.size;
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
};
|
|
|
|
const getBookmarkCount = () => {
|
|
if (video && 'bookmark_count' in video) return video.bookmark_count;
|
|
return 0;
|
|
};
|
|
|
|
const getAvgRating = () => {
|
|
if (video && 'avg_rating' in video) return video.avg_rating;
|
|
return 0;
|
|
};
|
|
|
|
const getVideoId = () => {
|
|
if (video && 'id' in video && video.id !== undefined) return video.id;
|
|
return 0;
|
|
};
|
|
|
|
const formatTime = (time: number) => {
|
|
const minutes = Math.floor(time / 60);
|
|
const seconds = Math.floor(time % 60);
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
if (!isOpen || typeof window === 'undefined') return null;
|
|
|
|
return createPortal(
|
|
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center">
|
|
<div className="relative w-full h-full max-w-7xl max-h-[90vh] mx-auto my-8">
|
|
{/* Close button */}
|
|
<button
|
|
onClick={onClose}
|
|
className="absolute top-4 right-4 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors"
|
|
>
|
|
<X className="h-6 w-6" />
|
|
</button>
|
|
|
|
{/* Video container */}
|
|
<div
|
|
className="relative w-full h-full bg-black rounded-lg overflow-hidden"
|
|
onMouseMove={() => setShowControls(true)}
|
|
onMouseLeave={() => setShowControls(false)}
|
|
>
|
|
<video
|
|
ref={videoRef}
|
|
className="w-full h-full object-contain"
|
|
onTimeUpdate={handleTimeUpdate}
|
|
onLoadedMetadata={handleLoadedMetadata}
|
|
onPlay={() => setIsPlaying(true)}
|
|
onPause={() => setIsPlaying(false)}
|
|
onMouseMove={() => setShowControls(true)}
|
|
onMouseLeave={() => setShowControls(false)}
|
|
>
|
|
<source src={`/api/stream/${getVideoId()}`} type="video/mp4" />
|
|
Your browser does not support the video tag.
|
|
</video>
|
|
|
|
{/* Title overlay */}
|
|
<div className={`absolute top-0 left-0 right-0 bg-gradient-to-b from-black/60 to-transparent p-4 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
|
<h2 className="text-white text-lg font-semibold">{getVideoTitle()}</h2>
|
|
</div>
|
|
|
|
{/* Controls overlay */}
|
|
<div className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
|
{/* Progress bar */}
|
|
<div className="mb-4">
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max={duration || 0}
|
|
value={currentTime}
|
|
onChange={handleSeek}
|
|
className="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
|
|
/>
|
|
<div className="flex justify-between text-white text-sm mt-1">
|
|
<span>{formatTime(currentTime)}</span>
|
|
<span>{formatTime(duration)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Video Info Bar (similar to photo viewer) */}
|
|
<div className="bg-black/70 backdrop-blur-sm rounded-lg p-4 mb-2">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-white font-medium">{getVideoTitle()}</h3>
|
|
<p className="text-gray-300 text-sm">{getVideoSize()}</p>
|
|
</div>
|
|
{(showBookmarks || showRatings) && (
|
|
<div className="flex items-center gap-4">
|
|
{showBookmarks && (
|
|
<button
|
|
onClick={isBookmarked ? handleUnbookmark : handleBookmark}
|
|
className="flex items-center gap-1 text-white hover:text-yellow-400 transition-colors"
|
|
>
|
|
<Bookmark className={`h-4 w-4 ${isBookmarked ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
|
</button>
|
|
)}
|
|
{showRatings && (
|
|
<div className="flex gap-1">
|
|
{[1, 2, 3, 4, 5].map((rating) => (
|
|
<button
|
|
key={rating}
|
|
onClick={() => handleRate(rating)}
|
|
className="text-white hover:text-yellow-400 transition-colors"
|
|
>
|
|
<Star className={`h-4 w-4 ${rating <= Math.round(getAvgRating()) ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Control buttons */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={handlePlayPause}
|
|
className="text-white hover:text-gray-300 transition-colors"
|
|
>
|
|
{isPlaying ? <Pause className="h-6 w-6" /> : <Play className="h-6 w-6" />}
|
|
</button>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleMute}
|
|
className="text-white hover:text-gray-300 transition-colors"
|
|
>
|
|
{isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
|
</button>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.1"
|
|
value={volume}
|
|
onChange={handleVolumeChange}
|
|
className="w-20 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleFullscreen}
|
|
className="text-white hover:text-gray-300 transition-colors"
|
|
>
|
|
<Maximize className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|