Compare commits

..

No commits in common. "2ac68f9a69143f91be253250e9681773adff2530" and "89bb05d3fcad5be7083ad05d31b3c958d17a1ae7" have entirely different histories.

10 changed files with 141 additions and 988 deletions

BIN
media.db

Binary file not shown.

View File

@ -1,6 +0,0 @@
<svg width="320" height="240" viewBox="0 0 320 240" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="320" height="240" fill="#f3f4f6"/>
<path d="M160 120m-40 0a40 40 0 1 0 80 0a40 40 0 1 0 -80 0" stroke="#9ca3af" stroke-width="2" fill="none"/>
<path d="M120 80h80v80h-80z" stroke="#9ca3af" stroke-width="2" fill="none"/>
<circle cx="140" cy="100" r="8" fill="#9ca3af"/>
</svg>

Before

Width:  |  Height:  |  Size: 397 B

View File

@ -1,59 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import db from "@/db";
import fs from "fs";
import path from "path";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const photoId = parseInt(id);
if (isNaN(photoId)) {
return NextResponse.json({ error: "Invalid photo ID" }, { status: 400 });
}
const photo = db.prepare("SELECT * FROM media WHERE id = ? AND type = 'photo'").get(photoId) as { path: string, title: string } | undefined;
if (!photo) {
return NextResponse.json({ error: "Photo not found" }, { status: 404 });
}
const photoPath = photo.path;
if (!fs.existsSync(photoPath)) {
return NextResponse.json({ error: "Photo file not found" }, { status: 404 });
}
const stat = fs.statSync(photoPath);
const fileSize = stat.size;
const ext = path.extname(photoPath).toLowerCase();
// Determine content type based on file extension
let contentType = 'image/jpeg'; // default
if (ext === '.png') contentType = 'image/png';
else if (ext === '.gif') contentType = 'image/gif';
else if (ext === '.webp') contentType = 'image/webp';
else if (ext === '.bmp') contentType = 'image/bmp';
else if (ext === '.tiff' || ext === '.tif') contentType = 'image/tiff';
else if (ext === '.svg') contentType = 'image/svg+xml';
const headers = new Headers({
"Content-Length": fileSize.toString(),
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000", // Cache for 1 year
});
const file = fs.createReadStream(photoPath);
return new Response(file as any, {
status: 200,
headers,
});
} catch (error) {
console.error("Error serving photo:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@ -2,27 +2,6 @@ import { NextResponse } from 'next/server';
import db from '@/db'; import db from '@/db';
export async function GET() { export async function GET() {
const photos = db.prepare(` const photos = db.prepare('SELECT * FROM media WHERE type = ?').all('photo');
SELECT
m.*,
COALESCE(b.bookmark_count, 0) as bookmark_count,
COALESCE(s.avg_rating, 0) as avg_rating,
COALESCE(s.star_count, 0) as star_count
FROM media m
LEFT JOIN (
SELECT media_id, COUNT(*) as bookmark_count
FROM bookmarks
GROUP BY media_id
) b ON m.id = b.media_id
LEFT JOIN (
SELECT
media_id,
AVG(rating) as avg_rating,
COUNT(*) as star_count
FROM stars
GROUP BY media_id
) s ON m.id = s.media_id
WHERE m.type = ?
`).all('photo');
return NextResponse.json(photos); return NextResponse.json(photos);
} }

View File

@ -3,9 +3,9 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import InlineVideoPlayer from "@/components/inline-video-player"; import InlineVideoPlayer from "@/components/inline-video-player";
import { Bookmark, Heart, Star, Film, Image as ImageIcon } from 'lucide-react'; import { Bookmark, Heart, Star, Film } from 'lucide-react';
interface MediaItem { interface Video {
id: number; id: number;
title: string; title: string;
path: string; path: string;
@ -19,23 +19,23 @@ interface MediaItem {
} }
export default function BookmarksPage() { export default function BookmarksPage() {
const [items, setItems] = useState<MediaItem[]>([]); const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedItem, setSelectedItem] = useState<MediaItem | null>(null); const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
const [isPlayerOpen, setIsPlayerOpen] = useState(false); const [isPlayerOpen, setIsPlayerOpen] = useState(false);
const [scrollPosition, setScrollPosition] = useState(0); const [scrollPosition, setScrollPosition] = useState(0);
useEffect(() => { useEffect(() => {
fetchBookmarkedItems(); fetchBookmarkedVideos();
}, []); }, []);
const fetchBookmarkedItems = async () => { const fetchBookmarkedVideos = async () => {
try { try {
const response = await fetch('/api/bookmarks'); const response = await fetch('/api/bookmarks');
const data = await response.json(); const data = await response.json();
setItems(data); setVideos(data);
} catch (error) { } catch (error) {
console.error('Error fetching bookmarked items:', error); console.error('Error fetching bookmarked videos:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -49,16 +49,16 @@ export default function BookmarksPage() {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}; };
const handleItemClick = (item: MediaItem) => { const handleVideoClick = (video: Video) => {
setScrollPosition(window.scrollY); setScrollPosition(window.scrollY);
setSelectedItem(item); setSelectedVideo(video);
setIsPlayerOpen(true); setIsPlayerOpen(true);
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}; };
const handleClosePlayer = () => { const handleClosePlayer = () => {
setIsPlayerOpen(false); setIsPlayerOpen(false);
setSelectedItem(null); setSelectedVideo(null);
// Restore scroll position // Restore scroll position
setTimeout(() => { setTimeout(() => {
window.scrollTo({ top: scrollPosition, behavior: 'smooth' }); window.scrollTo({ top: scrollPosition, behavior: 'smooth' });
@ -70,7 +70,7 @@ export default function BookmarksPage() {
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-muted-foreground">Loading bookmarked items...</p> <p className="text-muted-foreground">Loading bookmarked videos...</p>
</div> </div>
</div> </div>
); );
@ -85,15 +85,15 @@ export default function BookmarksPage() {
<Bookmark className="h-6 w-6 text-white" /> <Bookmark className="h-6 w-6 text-white" />
</div> </div>
<div> <div>
<h1 className="text-3xl font-bold text-white">Bookmarked Items</h1> <h1 className="text-3xl font-bold text-white">Bookmarked Videos</h1>
<p className="text-zinc-400 mt-1"> <p className="text-zinc-400 mt-1">
{items.length} {items.length === 1 ? 'item' : 'items'} bookmarked {videos.length} {videos.length === 1 ? 'video' : 'videos'} bookmarked
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{items.length === 0 ? ( {videos.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[60vh]"> <div className="flex flex-col items-center justify-center min-h-[60vh]">
<div className="text-center max-w-md"> <div className="text-center max-w-md">
<div className="w-20 h-20 bg-zinc-900 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg"> <div className="w-20 h-20 bg-zinc-900 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg">
@ -101,33 +101,29 @@ export default function BookmarksPage() {
</div> </div>
<h2 className="text-2xl font-bold text-white mb-2">No Bookmarks Yet</h2> <h2 className="text-2xl font-bold text-white mb-2">No Bookmarks Yet</h2>
<p className="text-zinc-400"> <p className="text-zinc-400">
Start bookmarking videos and photos by clicking the bookmark icon. Start bookmarking videos by clicking the bookmark icon in the video player.
</p> </p>
</div> </div>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
{items.map((item) => ( {videos.map((video) => (
<Card <Card
key={item.id} key={video.id}
className="group bg-white dark:bg-slate-800 border-0 shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden cursor-pointer" className="group bg-white dark:bg-slate-800 border-0 shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden cursor-pointer"
onClick={() => handleItemClick(item)} onClick={() => handleVideoClick(video)}
> >
<CardHeader className="p-0"> <CardHeader className="p-0">
<div className={`relative overflow-hidden ${item.type === 'photo' ? 'aspect-square' : 'aspect-video'} bg-slate-200 dark:bg-slate-700`}> <div className="aspect-video bg-slate-200 dark:bg-slate-700 relative overflow-hidden">
{item.thumbnail ? ( {video.thumbnail ? (
<img <img
src={item.thumbnail} src={video.thumbnail}
alt={item.title} alt={video.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/> />
) : ( ) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-slate-200 to-slate-300 dark:from-slate-700 dark:to-slate-800"> <div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-slate-200 to-slate-300 dark:from-slate-700 dark:to-slate-800">
{item.type === 'photo' ? ( <Film className="w-12 h-12 text-slate-400" />
<ImageIcon className="w-12 h-12 text-slate-400" />
) : (
<Film className="w-12 h-12 text-slate-400" />
)}
</div> </div>
)} )}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" /> <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
@ -135,34 +131,26 @@ export default function BookmarksPage() {
</CardHeader> </CardHeader>
<CardContent className="p-3"> <CardContent className="p-3">
<CardTitle className="text-sm font-semibold text-slate-900 dark:text-slate-100 truncate mb-1"> <CardTitle className="text-sm font-semibold text-slate-900 dark:text-slate-100 truncate mb-1">
{item.title || item.path.split('/').pop()} {video.title || video.path.split('/').pop()}
</CardTitle> </CardTitle>
<CardDescription className="text-xs text-slate-600 dark:text-slate-400"> <CardDescription className="text-xs text-slate-600 dark:text-slate-400">
{formatFileSize(item.size)} {formatFileSize(video.size)}
</CardDescription> </CardDescription>
{/* Stats */} {/* Stats */}
<div className="flex items-center gap-3 mt-2 text-xs text-slate-500 dark:text-slate-400"> <div className="flex items-center gap-3 mt-2 text-xs text-slate-500 dark:text-slate-400">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Heart className="h-3 w-3" /> <Heart className="h-3 w-3" />
<span>{item.bookmark_count || 0}</span> <span>{video.bookmark_count || 0}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Star className="h-3 w-3" /> <Star className="h-3 w-3" />
<span>{item.avg_rating?.toFixed(1) || '0.0'}</span> <span>{video.avg_rating?.toFixed(1) || '0.0'}</span>
</div> </div>
</div> </div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1 truncate"> <div className="text-xs text-slate-500 dark:text-slate-400 mt-1 truncate">
{item.type === 'photo' ? ( {video.library_path?.split('/').pop()}
<span className="flex items-center gap-1">
<ImageIcon className="h-3 w-3" /> {item.library_path?.split('/').pop()}
</span>
) : (
<span className="flex items-center gap-1">
<Film className="h-3 w-3" /> {item.library_path?.split('/').pop()}
</span>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -172,14 +160,14 @@ export default function BookmarksPage() {
</div> </div>
{/* Inline Video Player */} {/* Inline Video Player */}
{selectedItem && selectedItem.type === 'video' && ( {selectedVideo && (
<InlineVideoPlayer <InlineVideoPlayer
video={{ video={{
id: selectedItem.id, id: selectedVideo.id,
title: selectedItem.title || selectedItem.path.split('/').pop() || 'Untitled', title: selectedVideo.title || selectedVideo.path.split('/').pop() || 'Untitled',
path: selectedItem.path, path: selectedVideo.path,
size: selectedItem.size, size: selectedVideo.size,
thumbnail: selectedItem.thumbnail || '', thumbnail: selectedVideo.thumbnail || '',
}} }}
isOpen={isPlayerOpen} isOpen={isPlayerOpen}
onClose={handleClosePlayer} onClose={handleClosePlayer}

View File

@ -6,9 +6,8 @@ import { useSearchParams } from "next/navigation";
import { Folder, File, Image, Film, Play } from "lucide-react"; import { Folder, File, Image, Film, Play } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import PhotoViewer from "@/components/photo-viewer"; import InlineVideoPlayer from "@/components/inline-video-player";
import VideoViewer from "@/components/video-viewer"; import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
interface FileSystemItem { interface FileSystemItem {
name: string; name: string;
@ -28,9 +27,6 @@ const FolderViewerPage = () => {
const [selectedVideo, setSelectedVideo] = useState<FileSystemItem | null>(null); const [selectedVideo, setSelectedVideo] = useState<FileSystemItem | null>(null);
const [isPlayerOpen, setIsPlayerOpen] = useState(false); const [isPlayerOpen, setIsPlayerOpen] = useState(false);
const [isVideoLoading, setIsVideoLoading] = useState(false); const [isVideoLoading, setIsVideoLoading] = useState(false);
const [selectedPhoto, setSelectedPhoto] = useState<FileSystemItem | null>(null);
const [isPhotoViewerOpen, setIsPhotoViewerOpen] = useState(false);
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
useEffect(() => { useEffect(() => {
if (path) { if (path) {
@ -85,37 +81,6 @@ const FolderViewerPage = () => {
setSelectedVideo(null); setSelectedVideo(null);
}; };
const handlePhotoClick = (item: FileSystemItem, index: number) => {
if (item.type === 'photo' && item.id) {
setSelectedPhoto(item);
setCurrentPhotoIndex(index);
setIsPhotoViewerOpen(true);
}
};
const handleClosePhotoViewer = () => {
setIsPhotoViewerOpen(false);
setSelectedPhoto(null);
};
const handleNextPhoto = () => {
const photoItems = items.filter(item => item.type === 'photo' && item.id);
if (photoItems.length > 0) {
const nextIndex = (currentPhotoIndex + 1) % photoItems.length;
setCurrentPhotoIndex(nextIndex);
setSelectedPhoto(photoItems[nextIndex]);
}
};
const handlePrevPhoto = () => {
const photoItems = items.filter(item => item.type === 'photo' && item.id);
if (photoItems.length > 0) {
const prevIndex = (currentPhotoIndex - 1 + photoItems.length) % photoItems.length;
setCurrentPhotoIndex(prevIndex);
setSelectedPhoto(photoItems[prevIndex]);
}
};
if (!path) { if (!path) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
@ -171,17 +136,13 @@ const FolderViewerPage = () => {
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
{items.map((item) => ( {items.map((item) => (
<div key={item.name} <div key={item.name}
className={`group relative bg-white dark:bg-slate-800 rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden ${(item.type === 'video' || item.type === 'photo') ? 'cursor-pointer' : ''}`}> className={`group relative bg-white dark:bg-slate-800 rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden ${item.type === 'video' ? 'cursor-pointer' : ''}`}>
<Link href={item.isDirectory ? `/folder-viewer?path=${item.path}` : '#'} <Link href={item.isDirectory ? `/folder-viewer?path=${item.path}` : '#'}
className="block" className="block"
onClick={(e) => { onClick={(e) => {
if (item.type === 'video' && item.id) { if (item.type === 'video' && item.id) {
e.preventDefault(); e.preventDefault();
handleVideoClick(item); handleVideoClick(item);
} else if (item.type === 'photo' && item.id) {
e.preventDefault();
const photoIndex = items.filter(i => i.type === 'photo' && i.id).findIndex(i => i.id === item.id);
handlePhotoClick(item, photoIndex);
} }
}}> }}>
<div className="aspect-square relative overflow-hidden"> <div className="aspect-square relative overflow-hidden">
@ -195,7 +156,7 @@ const FolderViewerPage = () => {
) : isMediaFile(item) ? ( ) : isMediaFile(item) ? (
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<img <img
src={item.thumbnail || (item.type === 'video' ? '/placeholder-video.svg' : '/placeholder-photo.svg')} src={item.thumbnail || '/placeholder.svg'}
alt={item.name} alt={item.name}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/> />
@ -266,28 +227,21 @@ const FolderViewerPage = () => {
)} )}
</div> </div>
{/* Photo Viewer */} {/* Inline Video Player - Rendered as Portal */}
<PhotoViewer {selectedVideo && isPlayerOpen && typeof window !== 'undefined' && createPortal(
photo={selectedPhoto!} <InlineVideoPlayer
isOpen={isPhotoViewerOpen} video={{
onClose={handleClosePhotoViewer} id: selectedVideo.id!,
onNext={handleNextPhoto} title: selectedVideo.name,
onPrev={handlePrevPhoto} path: selectedVideo.path,
showNavigation={items.filter(item => item.type === 'photo' && item.id).length > 1} size: selectedVideo.size,
showBookmarks={false} thumbnail: selectedVideo.thumbnail || '',
showRatings={false} }}
formatFileSize={formatFileSize} isOpen={isPlayerOpen}
/> onClose={handleClosePlayer}
/>,
{/* Video Viewer */} document.body
<VideoViewer )}
video={selectedVideo!}
isOpen={isPlayerOpen}
onClose={handleClosePlayer}
showBookmarks={false}
showRatings={false}
formatFileSize={formatFileSize}
/>
</div> </div>
); );
}; };

View File

@ -1,12 +1,10 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Image as ImageIcon, Search, Filter, Star, Bookmark, HardDrive } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Image as ImageIcon } from 'lucide-react';
import PhotoViewer from '@/components/photo-viewer';
interface Photo { interface Photo {
id: number; id: number;
@ -15,18 +13,11 @@ interface Photo {
size: number; size: number;
thumbnail: string; thumbnail: string;
type: string; type: string;
bookmark_count: number;
avg_rating: number;
star_count: number;
} }
export default function PhotosPage() { export default function PhotosPage() {
const [photos, setPhotos] = useState<Photo[]>([]); const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const [isViewerOpen, setIsViewerOpen] = useState(false);
useEffect(() => { useEffect(() => {
fetchPhotos(); fetchPhotos();
@ -52,242 +43,88 @@ export default function PhotosPage() {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}; };
const filteredPhotos = photos.filter(photo =>
photo.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
photo.path.toLowerCase().includes(searchTerm.toLowerCase())
);
const handlePhotoClick = (photo: Photo, index: number) => {
setSelectedPhoto(photo);
setCurrentPhotoIndex(index);
setIsViewerOpen(true);
};
const handleCloseViewer = () => {
setIsViewerOpen(false);
setSelectedPhoto(null);
};
const handleNextPhoto = () => {
if (filteredPhotos.length > 0) {
const nextIndex = (currentPhotoIndex + 1) % filteredPhotos.length;
setCurrentPhotoIndex(nextIndex);
setSelectedPhoto(filteredPhotos[nextIndex]);
}
};
const handlePrevPhoto = () => {
if (filteredPhotos.length > 0) {
const prevIndex = (currentPhotoIndex - 1 + filteredPhotos.length) % filteredPhotos.length;
setCurrentPhotoIndex(prevIndex);
setSelectedPhoto(filteredPhotos[prevIndex]);
}
};
const handleBookmark = async (photoId: number) => {
try {
await fetch(`/api/bookmarks/${photoId}`, { method: 'POST' });
fetchPhotos();
} catch (error) {
console.error('Error bookmarking photo:', error);
}
};
const handleUnbookmark = async (photoId: number) => {
try {
await fetch(`/api/bookmarks/${photoId}`, { method: 'DELETE' });
fetchPhotos();
} catch (error) {
console.error('Error unbookmarking photo:', error);
}
};
const handleRate = async (photoId: number, rating: number) => {
try {
await fetch(`/api/stars/${photoId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating })
});
fetchPhotos();
} catch (error) {
console.error('Error rating photo:', error);
}
};
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen p-6"> <div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="max-w-7xl mx-auto"> <div className="text-center">
<div className="flex items-center justify-center min-h-[60vh]"> <div className="w-16 h-16 bg-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-pulse shadow-lg shadow-purple-600/20">
<div className="text-center"> <ImageIcon className="h-8 w-8 text-white" />
<div className="w-16 h-16 bg-gradient-to-br from-primary to-primary/80 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-pulse shadow-lg">
<ImageIcon className="h-8 w-8 text-primary-foreground" />
</div>
<p className="text-muted-foreground font-medium">Loading photos...</p>
</div>
</div> </div>
<p className="text-zinc-400 font-medium">Loading photos...</p>
</div> </div>
</div> </div>
); );
} }
return ( return (
<> <div className="min-h-screen bg-zinc-950">
<div className="min-h-screen p-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="max-w-7xl mx-auto"> <div className="mb-8">
{/* Header */} <h1 className="text-5xl font-bold text-white tracking-tight mb-2">
<div className="mb-8"> Photos
<div className="flex items-center gap-4 mb-4"> </h1>
<div className="w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center shadow-lg"> <p className="text-zinc-400 text-lg">
<ImageIcon className="h-6 w-6 text-white" /> {photos.length} {photos.length === 1 ? 'photo' : 'photos'} found
</div> </p>
<div> </div>
<h1 className="text-3xl font-bold text-foreground tracking-tight">
Photos
</h1>
<p className="text-muted-foreground">
{photos.length} {photos.length === 1 ? 'photo' : 'photos'} in your library
</p>
</div>
</div>
{/* Search and Filter Bar */} {photos.length > 0 ? (
<div className="flex flex-col sm:flex-row gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-6">
<div className="relative flex-1"> {photos.map((photo) => (
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <div key={photo.id}
<Input className="group relative bg-zinc-900 rounded-lg overflow-hidden cursor-pointer transition-all duration-300 hover:scale-105 hover:shadow-2xl hover:shadow-purple-600/20">
placeholder="Search photos..." <div className="aspect-square relative overflow-hidden">
value={searchTerm} <img
onChange={(e) => setSearchTerm(e.target.value)} src={photo.thumbnail}
className="pl-10 bg-background border-border" alt={photo.title}
/> className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-image.jpg';
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="absolute bottom-0 left-0 right-0 p-4 transform translate-y-full group-hover:translate-y-0 transition-transform duration-300">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-purple-600 rounded-full flex items-center justify-center">
<ImageIcon className="h-4 w-4 text-white" />
</div>
<span className="text-white text-sm font-medium">View</span>
</div>
</div>
</div>
<div className="p-4">
<p className="text-white font-medium text-sm line-clamp-2 mb-1">
{photo.title}
</p>
<p className="text-zinc-400 text-xs">
{formatFileSize(photo.size)}
</p>
</div>
</div> </div>
<Button variant="outline" className="shrink-0"> ))}
<Filter className="h-4 w-4 mr-2" /> </div>
Filter ) : (
</Button> <div className="text-center py-20">
<div className="max-w-sm mx-auto">
<div className="w-20 h-20 bg-zinc-900 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<ImageIcon className="h-10 w-10 text-purple-500" />
</div>
<h3 className="text-2xl font-semibold text-white mb-2">
No Photos Found
</h3>
<p className="text-zinc-400 mb-6">
Add media libraries to scan for photos
</p>
<Link href="/settings">
<button className="bg-purple-600 hover:bg-purple-700 text-white px-6 py-3 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-purple-600/20">
Add Library
</button>
</Link>
</div> </div>
</div> </div>
)}
{/* Photos Grid */}
{filteredPhotos.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-6">
{filteredPhotos.map((photo, index) => (
<Card
key={photo.id}
className="group hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer border-border overflow-hidden"
onClick={() => handlePhotoClick(photo, index)}
>
<div className="aspect-square relative overflow-hidden bg-muted">
<img
src={photo.thumbnail || "/placeholder-photo.svg"}
alt={photo.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-photo.svg';
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
{/* Photo Type Badge */}
<div className="absolute top-2 right-2">
<div className="bg-black/70 backdrop-blur-sm rounded-full px-2 py-1">
<ImageIcon className="h-3 w-3 text-white" />
</div>
</div>
{/* Bookmark and Rating Overlay */}
<div className="absolute top-2 left-2 flex gap-2">
<button
onClick={(e) => {
e.stopPropagation();
photo.bookmark_count > 0 ? handleUnbookmark(photo.id) : handleBookmark(photo.id);
}}
className="bg-black/70 backdrop-blur-sm rounded-full p-1.5 hover:bg-black/90 transition-colors"
>
<Bookmark className={`h-3 w-3 ${photo.bookmark_count > 0 ? 'fill-yellow-400 text-yellow-400' : 'text-white'}`} />
</button>
</div>
{/* Rating Stars */}
{photo.avg_rating > 0 && (
<div className="absolute bottom-2 right-2 flex gap-0.5 bg-black/70 backdrop-blur-sm rounded-full px-1.5 py-1">
{[...Array(5)].map((_, i) => (
<Star key={i} className={`h-2.5 w-2.5 ${i < Math.round(photo.avg_rating) ? 'fill-yellow-400 text-yellow-400' : 'text-gray-400'}`} />
))}
</div>
)}
</div>
<CardContent className="p-3">
<h3 className="font-medium text-foreground text-sm line-clamp-2 mb-1 group-hover:text-primary transition-colors">
{photo.title}
</h3>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
<span>{formatFileSize(photo.size)}</span>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
{photo.path}
</p>
</CardContent>
</Card>
))}
</div>
) : searchTerm ? (
<div className="text-center py-20">
<div className="max-w-sm mx-auto">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">No photos found</h3>
<p className="text-muted-foreground mb-4">Try adjusting your search terms</p>
<Button
variant="outline"
onClick={() => setSearchTerm('')}
>
Clear search
</Button>
</div>
</div>
) : (
<div className="text-center py-20">
<div className="max-w-sm mx-auto">
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
<ImageIcon className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">No Photos Found</h3>
<p className="text-muted-foreground mb-6">Add media libraries and scan for photos to get started</p>
<Link href="/settings">
<Button>
<ImageIcon className="h-4 w-4 mr-2" />
Add Library
</Button>
</Link>
</div>
</div>
)}
</div>
</div> </div>
</div>
{/* 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}
/>
</>
); );
} }

View File

@ -6,7 +6,8 @@ import { Film, Play, Clock, HardDrive, Search, Filter } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import VideoViewer from "@/components/video-viewer"; import InlineVideoPlayer from "@/components/inline-video-player";
import { createPortal } from "react-dom";
interface Video { interface Video {
id: number; id: number;
@ -14,10 +15,6 @@ interface Video {
path: string; path: string;
size: number; size: number;
thumbnail: string; thumbnail: string;
type: string;
bookmark_count: number;
avg_rating: number;
star_count: number;
} }
const VideosPage = () => { const VideosPage = () => {
@ -133,11 +130,11 @@ const VideosPage = () => {
> >
<div className="aspect-video relative overflow-hidden bg-muted"> <div className="aspect-video relative overflow-hidden bg-muted">
<img <img
src={video.thumbnail || "/placeholder-video.svg"} src={video.thumbnail || "/placeholder.svg"}
alt={video.title} alt={video.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-video.svg'; (e.target as HTMLImageElement).src = '/placeholder.svg';
}} }}
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" /> <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
@ -210,15 +207,16 @@ const VideosPage = () => {
</div> </div>
</div> </div>
{/* Video Viewer */} {/* Inline Video Player - Rendered as Portal */}
<VideoViewer {selectedVideo && isPlayerOpen && typeof window !== 'undefined' && createPortal(
video={selectedVideo!} <InlineVideoPlayer
isOpen={isPlayerOpen} video={selectedVideo}
onClose={handleClosePlayer} isOpen={isPlayerOpen}
showBookmarks={true} onClose={handleClosePlayer}
showRatings={true} scrollPosition={scrollPosition}
formatFileSize={formatFileSize} />,
/> document.body
)}
</> </>
); );
}; };

View File

@ -1,223 +0,0 @@
'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
);
}

View File

@ -1,315 +0,0 @@
'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
);
}