feat: refactor media viewers for improved functionality and UI
- Replaced inline video player with a dedicated VideoViewer component for enhanced video playback experience. - Updated the PhotosPage and FolderViewerPage to utilize the new PhotoViewer and VideoViewer components, streamlining the media viewing process. - Removed unnecessary loading states and modal implementations, simplifying the code structure and improving performance. - Enhanced the PhotoViewer and VideoViewer with bookmarking and rating features for better user interaction.
This commit is contained in:
parent
444f6288fe
commit
2ac68f9a69
|
|
@ -6,10 +6,9 @@ import { useSearchParams } from "next/navigation";
|
|||
import { Folder, File, Image, Film, Play } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import InlineVideoPlayer from "@/components/inline-video-player";
|
||||
import { createPortal } from "react-dom";
|
||||
import PhotoViewer from "@/components/photo-viewer";
|
||||
import VideoViewer from "@/components/video-viewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface FileSystemItem {
|
||||
name: string;
|
||||
|
|
@ -31,7 +30,6 @@ const FolderViewerPage = () => {
|
|||
const [isVideoLoading, setIsVideoLoading] = useState(false);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<FileSystemItem | null>(null);
|
||||
const [isPhotoViewerOpen, setIsPhotoViewerOpen] = useState(false);
|
||||
const [isPhotoLoading, setIsPhotoLoading] = useState(false);
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -92,14 +90,12 @@ const FolderViewerPage = () => {
|
|||
setSelectedPhoto(item);
|
||||
setCurrentPhotoIndex(index);
|
||||
setIsPhotoViewerOpen(true);
|
||||
setIsPhotoLoading(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClosePhotoViewer = () => {
|
||||
setIsPhotoViewerOpen(false);
|
||||
setSelectedPhoto(null);
|
||||
setIsPhotoLoading(false);
|
||||
};
|
||||
|
||||
const handleNextPhoto = () => {
|
||||
|
|
@ -108,7 +104,6 @@ const FolderViewerPage = () => {
|
|||
const nextIndex = (currentPhotoIndex + 1) % photoItems.length;
|
||||
setCurrentPhotoIndex(nextIndex);
|
||||
setSelectedPhoto(photoItems[nextIndex]);
|
||||
setIsPhotoLoading(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -118,7 +113,6 @@ const FolderViewerPage = () => {
|
|||
const prevIndex = (currentPhotoIndex - 1 + photoItems.length) % photoItems.length;
|
||||
setCurrentPhotoIndex(prevIndex);
|
||||
setSelectedPhoto(photoItems[prevIndex]);
|
||||
setIsPhotoLoading(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -272,85 +266,28 @@ const FolderViewerPage = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Photo Viewer Modal */}
|
||||
{selectedPhoto && isPhotoViewerOpen && typeof window !== 'undefined' && createPortal(
|
||||
<div className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center" onClick={handleClosePhotoViewer}>
|
||||
<div className="relative w-full h-full max-w-7xl max-h-full p-4 flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handleClosePhotoViewer}
|
||||
className="bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Photo Viewer */}
|
||||
<PhotoViewer
|
||||
photo={selectedPhoto!}
|
||||
isOpen={isPhotoViewerOpen}
|
||||
onClose={handleClosePhotoViewer}
|
||||
onNext={handleNextPhoto}
|
||||
onPrev={handlePrevPhoto}
|
||||
showNavigation={items.filter(item => item.type === 'photo' && item.id).length > 1}
|
||||
showBookmarks={false}
|
||||
showRatings={false}
|
||||
formatFileSize={formatFileSize}
|
||||
/>
|
||||
|
||||
{/* Navigation Arrows */}
|
||||
{items.filter(item => item.type === 'photo' && item.id).length > 1 && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handlePrevPhoto}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handleNextPhoto}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isPhotoLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={`/api/photos/${selectedPhoto.id}`}
|
||||
alt={selectedPhoto.name}
|
||||
className={`max-w-full max-h-[90vh] w-auto h-auto object-contain rounded-lg ${isPhotoLoading ? 'hidden' : ''}`}
|
||||
onLoad={() => setIsPhotoLoading(false)}
|
||||
onError={() => setIsPhotoLoading(false)}
|
||||
/>
|
||||
|
||||
{/* Photo Info Bar */}
|
||||
<div className="absolute bottom-4 left-4 right-4 bg-black/70 backdrop-blur-sm rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{selectedPhoto.name}</h3>
|
||||
<p className="text-gray-300 text-sm">{formatFileSize(selectedPhoto.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Inline Video Player - Rendered as Portal */}
|
||||
{selectedVideo && isPlayerOpen && typeof window !== 'undefined' && createPortal(
|
||||
<InlineVideoPlayer
|
||||
video={{
|
||||
id: selectedVideo.id!,
|
||||
title: selectedVideo.name,
|
||||
path: selectedVideo.path,
|
||||
size: selectedVideo.size,
|
||||
thumbnail: selectedVideo.thumbnail || '',
|
||||
}}
|
||||
isOpen={isPlayerOpen}
|
||||
onClose={handleClosePlayer}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
{/* Video Viewer */}
|
||||
<VideoViewer
|
||||
video={selectedVideo!}
|
||||
isOpen={isPlayerOpen}
|
||||
onClose={handleClosePlayer}
|
||||
showBookmarks={false}
|
||||
showRatings={false}
|
||||
formatFileSize={formatFileSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Image as ImageIcon, Search, Filter, Star, Bookmark, HardDrive, X, ZoomIn, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Image as ImageIcon, Search, Filter, Star, Bookmark, HardDrive } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { createPortal } from 'react-dom';
|
||||
import PhotoViewer from '@/components/photo-viewer';
|
||||
|
||||
interface Photo {
|
||||
id: number;
|
||||
|
|
@ -27,7 +27,6 @@ export default function PhotosPage() {
|
|||
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||
const [isPhotoLoading, setIsPhotoLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPhotos();
|
||||
|
|
@ -62,13 +61,11 @@ export default function PhotosPage() {
|
|||
setSelectedPhoto(photo);
|
||||
setCurrentPhotoIndex(index);
|
||||
setIsViewerOpen(true);
|
||||
setIsPhotoLoading(true);
|
||||
};
|
||||
|
||||
const handleCloseViewer = () => {
|
||||
setIsViewerOpen(false);
|
||||
setSelectedPhoto(null);
|
||||
setIsPhotoLoading(false);
|
||||
};
|
||||
|
||||
const handleNextPhoto = () => {
|
||||
|
|
@ -76,7 +73,6 @@ export default function PhotosPage() {
|
|||
const nextIndex = (currentPhotoIndex + 1) % filteredPhotos.length;
|
||||
setCurrentPhotoIndex(nextIndex);
|
||||
setSelectedPhoto(filteredPhotos[nextIndex]);
|
||||
setIsPhotoLoading(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -85,7 +81,6 @@ export default function PhotosPage() {
|
|||
const prevIndex = (currentPhotoIndex - 1 + filteredPhotos.length) % filteredPhotos.length;
|
||||
setCurrentPhotoIndex(prevIndex);
|
||||
setSelectedPhoto(filteredPhotos[prevIndex]);
|
||||
setIsPhotoLoading(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -278,88 +273,21 @@ export default function PhotosPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photo Viewer Modal */}
|
||||
{selectedPhoto && isViewerOpen && typeof window !== 'undefined' && createPortal(
|
||||
<div className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center" onClick={handleCloseViewer}>
|
||||
<div className="relative w-full h-full max-w-7xl max-h-full p-4 flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handleCloseViewer}
|
||||
className="bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation Arrows */}
|
||||
{filteredPhotos.length > 1 && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handlePrevPhoto}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handleNextPhoto}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isPhotoLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={`/api/photos/${selectedPhoto.id}`}
|
||||
alt={selectedPhoto.title}
|
||||
className={`max-w-full max-h-[90vh] w-auto h-auto object-contain rounded-lg ${isPhotoLoading ? 'hidden' : ''}`}
|
||||
onLoad={() => setIsPhotoLoading(false)}
|
||||
onError={() => setIsPhotoLoading(false)}
|
||||
/>
|
||||
|
||||
{/* Photo Info Bar */}
|
||||
<div className="absolute bottom-4 left-4 right-4 bg-black/70 backdrop-blur-sm rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{selectedPhoto.title}</h3>
|
||||
<p className="text-gray-300 text-sm">{formatFileSize(selectedPhoto.size)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => selectedPhoto.bookmark_count > 0 ? handleUnbookmark(selectedPhoto.id) : handleBookmark(selectedPhoto.id)}
|
||||
className="flex items-center gap-1 text-white hover:text-yellow-400 transition-colors"
|
||||
>
|
||||
<Bookmark className={`h-4 w-4 ${selectedPhoto.bookmark_count > 0 ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((rating) => (
|
||||
<button
|
||||
key={rating}
|
||||
onClick={() => handleRate(selectedPhoto.id, rating)}
|
||||
className="text-white hover:text-yellow-400 transition-colors"
|
||||
>
|
||||
<Star className={`h-4 w-4 ${rating <= Math.round(selectedPhoto.avg_rating) ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{/* Photo Viewer */}
|
||||
<PhotoViewer
|
||||
photo={selectedPhoto!}
|
||||
isOpen={isViewerOpen}
|
||||
onClose={handleCloseViewer}
|
||||
onNext={handleNextPhoto}
|
||||
onPrev={handlePrevPhoto}
|
||||
showNavigation={filteredPhotos.length > 1}
|
||||
showBookmarks={true}
|
||||
showRatings={true}
|
||||
formatFileSize={formatFileSize}
|
||||
onBookmark={handleBookmark}
|
||||
onUnbookmark={handleUnbookmark}
|
||||
onRate={handleRate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,8 +6,7 @@ import { Film, Play, Clock, HardDrive, Search, Filter } from "lucide-react";
|
|||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import InlineVideoPlayer from "@/components/inline-video-player";
|
||||
import { createPortal } from "react-dom";
|
||||
import VideoViewer from "@/components/video-viewer";
|
||||
|
||||
interface Video {
|
||||
id: number;
|
||||
|
|
@ -15,6 +14,10 @@ interface Video {
|
|||
path: string;
|
||||
size: number;
|
||||
thumbnail: string;
|
||||
type: string;
|
||||
bookmark_count: number;
|
||||
avg_rating: number;
|
||||
star_count: number;
|
||||
}
|
||||
|
||||
const VideosPage = () => {
|
||||
|
|
@ -207,16 +210,15 @@ const VideosPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline Video Player - Rendered as Portal */}
|
||||
{selectedVideo && isPlayerOpen && typeof window !== 'undefined' && createPortal(
|
||||
<InlineVideoPlayer
|
||||
video={selectedVideo}
|
||||
isOpen={isPlayerOpen}
|
||||
onClose={handleClosePlayer}
|
||||
scrollPosition={scrollPosition}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
{/* Video Viewer */}
|
||||
<VideoViewer
|
||||
video={selectedVideo!}
|
||||
isOpen={isPlayerOpen}
|
||||
onClose={handleClosePlayer}
|
||||
showBookmarks={true}
|
||||
showRatings={true}
|
||||
formatFileSize={formatFileSize}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, Star, Bookmark } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface Photo {
|
||||
id: number;
|
||||
path: string;
|
||||
title: 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 PhotoViewerProps {
|
||||
photo: Photo | FileSystemItem;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onNext?: () => void;
|
||||
onPrev?: () => void;
|
||||
showNavigation?: boolean;
|
||||
showBookmarks?: boolean;
|
||||
showRatings?: boolean;
|
||||
formatFileSize?: (bytes: number) => string;
|
||||
onBookmark?: (photoId: number) => void;
|
||||
onUnbookmark?: (photoId: number) => void;
|
||||
onRate?: (photoId: number, rating: number) => void;
|
||||
}
|
||||
|
||||
export default function PhotoViewer({
|
||||
photo,
|
||||
isOpen,
|
||||
onClose,
|
||||
onNext,
|
||||
onPrev,
|
||||
showNavigation = false,
|
||||
showBookmarks = false,
|
||||
showRatings = false,
|
||||
formatFileSize,
|
||||
onBookmark,
|
||||
onUnbookmark,
|
||||
onRate
|
||||
}: PhotoViewerProps) {
|
||||
const [isPhotoLoading, setIsPhotoLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsPhotoLoading(true);
|
||||
}
|
||||
}, [isOpen, photo]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsPhotoLoading(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (onNext) {
|
||||
setIsPhotoLoading(true);
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (onPrev) {
|
||||
setIsPhotoLoading(true);
|
||||
onPrev();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBookmark = () => {
|
||||
if (onBookmark && 'id' in photo) {
|
||||
onBookmark(photo.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnbookmark = () => {
|
||||
if (onUnbookmark && 'id' in photo) {
|
||||
onUnbookmark(photo.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRate = (rating: number) => {
|
||||
if (onRate && 'id' in photo) {
|
||||
onRate(photo.id, rating);
|
||||
}
|
||||
};
|
||||
|
||||
const getPhotoTitle = () => {
|
||||
if ('title' in photo) return photo.title;
|
||||
if ('name' in photo) return photo.name;
|
||||
return 'Photo';
|
||||
};
|
||||
|
||||
const getPhotoSize = () => {
|
||||
if (formatFileSize) {
|
||||
return formatFileSize(photo.size);
|
||||
}
|
||||
// Default format function
|
||||
const bytes = photo.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 ('bookmark_count' in photo) return photo.bookmark_count;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const getAvgRating = () => {
|
||||
if ('avg_rating' in photo) return photo.avg_rating;
|
||||
return 0;
|
||||
};
|
||||
|
||||
if (!isOpen || typeof window === 'undefined') return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center" onClick={handleClose}>
|
||||
<div className="relative w-full h-full max-w-7xl max-h-full p-4 flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handleClose}
|
||||
className="bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation Arrows */}
|
||||
{showNavigation && onNext && onPrev && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handlePrev}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handleNext}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 bg-black/50 backdrop-blur-sm hover:bg-black/70"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isPhotoLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={`/api/photos/${('id' in photo ? photo.id : photo.id) || ''}`}
|
||||
alt={getPhotoTitle()}
|
||||
className={`max-w-full max-h-[90vh] w-auto h-auto object-contain rounded-lg ${isPhotoLoading ? 'hidden' : ''}`}
|
||||
onLoad={() => setIsPhotoLoading(false)}
|
||||
onError={() => setIsPhotoLoading(false)}
|
||||
/>
|
||||
|
||||
{/* Photo Info Bar */}
|
||||
<div className="absolute bottom-4 left-4 right-4 bg-black/70 backdrop-blur-sm rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{getPhotoTitle()}</h3>
|
||||
<p className="text-gray-300 text-sm">{getPhotoSize()}</p>
|
||||
</div>
|
||||
{(showBookmarks || showRatings) && (
|
||||
<div className="flex items-center gap-4">
|
||||
{showBookmarks && (
|
||||
<button
|
||||
onClick={getBookmarkCount() > 0 ? handleUnbookmark : handleBookmark}
|
||||
className="flex items-center gap-1 text-white hover:text-yellow-400 transition-colors"
|
||||
>
|
||||
<Bookmark className={`h-4 w-4 ${getBookmarkCount() > 0 ? '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>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,315 @@
|
|||
'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 videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && videoRef.current) {
|
||||
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]);
|
||||
|
||||
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 && 'id' in video) {
|
||||
onBookmark(video.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnbookmark = () => {
|
||||
if (onUnbookmark && 'id' in video) {
|
||||
onUnbookmark(video.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRate = (rating: number) => {
|
||||
if (onRate && 'id' in video) {
|
||||
onRate(video.id, rating);
|
||||
}
|
||||
};
|
||||
|
||||
const getVideoTitle = () => {
|
||||
if ('title' in video) return video.title;
|
||||
if ('name' in video) return video.name;
|
||||
return 'Video';
|
||||
};
|
||||
|
||||
const getVideoSize = () => {
|
||||
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 ('bookmark_count' in video) return video.bookmark_count;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const getAvgRating = () => {
|
||||
if ('avg_rating' in video) return video.avg_rating;
|
||||
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/${('id' in video ? video.id : video.id) || ''}`} 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'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-white text-lg font-semibold">{getVideoTitle()}</h2>
|
||||
{(showBookmarks || showRatings) && (
|
||||
<div className="flex items-center gap-4">
|
||||
{showBookmarks && (
|
||||
<button
|
||||
onClick={getBookmarkCount() > 0 ? handleUnbookmark : handleBookmark}
|
||||
className="flex items-center gap-1 text-white hover:text-yellow-400 transition-colors"
|
||||
>
|
||||
<Bookmark className={`h-4 w-4 ${getBookmarkCount() > 0 ? '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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue