nextav/src/app/videos/page.tsx

310 lines
11 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Film, Play, Clock, HardDrive, Search, Filter, Star } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import VideoViewer from "@/components/video-viewer";
import { StarRating } from "@/components/star-rating";
interface Video {
id: number;
title: string;
path: string;
size: number;
thumbnail: string;
type: string;
bookmark_count: number;
avg_rating: number;
star_count: number;
}
const VideosPage = () => {
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
const [scrollPosition, setScrollPosition] = useState(0);
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
useEffect(() => {
fetchVideos();
}, []);
const fetchVideos = async () => {
try {
const res = await fetch("/api/videos");
const data = await res.json();
setVideos(data);
} catch (error) {
console.error('Error fetching videos:', error);
} finally {
setLoading(false);
}
};
const formatFileSize = (bytes: number) => {
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 formatFilePath = (path: string) => {
if (!path) return '';
// Split path into directory and filename
const lastSlashIndex = path.lastIndexOf('/');
const lastBackslashIndex = path.lastIndexOf('\\');
const lastSeparatorIndex = Math.max(lastSlashIndex, lastBackslashIndex);
if (lastSeparatorIndex === -1) {
// No directory separator found, return as is
return path;
}
const directory = path.substring(0, lastSeparatorIndex);
const filename = path.substring(lastSeparatorIndex + 1);
// If directory is short enough, show it all
if (directory.length <= 30) {
return `${directory}/${filename}`;
}
// Truncate directory with ellipsis in the middle
const maxDirLength = 25;
const startLength = Math.floor(maxDirLength / 2);
const endLength = maxDirLength - startLength - 3; // -3 for "..."
const truncatedDir = directory.length > maxDirLength
? `${directory.substring(0, startLength)}...${directory.substring(directory.length - endLength)}`
: directory;
return `${truncatedDir}/${filename}`;
};
const filteredVideos = videos.filter(video =>
video.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
video.path.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleVideoClick = (video: Video) => {
setSelectedVideo(video);
setIsPlayerOpen(true);
};
const handleClosePlayer = () => {
setIsPlayerOpen(false);
setSelectedVideo(null);
};
const handleBookmark = async (videoId: number) => {
try {
await fetch(`/api/bookmarks/${videoId}`, { method: 'POST' });
fetchVideos();
} catch (error) {
console.error('Error bookmarking video:', error);
}
};
const handleUnbookmark = async (videoId: number) => {
try {
await fetch(`/api/bookmarks/${videoId}`, { method: 'DELETE' });
fetchVideos();
} catch (error) {
console.error('Error unbookmarking video:', error);
}
};
const handleRate = async (videoId: number, rating: number) => {
try {
await fetch(`/api/stars/${videoId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating })
});
fetchVideos();
} catch (error) {
console.error('Error rating video:', error);
}
};
if (loading) {
return (
<div className="min-h-screen p-6">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<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">
<Film className="h-8 w-8 text-primary-foreground" />
</div>
<p className="text-muted-foreground font-medium">Loading videos...</p>
</div>
</div>
</div>
</div>
);
}
return (
<>
<div className="min-h-screen p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center shadow-lg">
<Film className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-foreground tracking-tight">
Videos
</h1>
<p className="text-muted-foreground">
{videos.length} {videos.length === 1 ? 'video' : 'videos'} in your library
</p>
</div>
</div>
{/* Search and Filter Bar */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search videos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-background border-border"
/>
</div>
<Button variant="outline" className="shrink-0">
<Filter className="h-4 w-4 mr-2" />
Filter
</Button>
</div>
</div>
{/* Videos Grid */}
{filteredVideos.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6">
{filteredVideos.map((video) => (
<Card
key={video.id}
className="group hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer border-border overflow-hidden"
onClick={() => handleVideoClick(video)}
>
<div className="aspect-video relative overflow-hidden bg-muted">
<img
src={video.thumbnail || "/placeholder-video.svg"}
alt={video.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-video.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" />
{/* Play Button Overlay */}
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="w-12 h-12 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center shadow-lg">
<Play className="h-5 w-5 text-foreground ml-0.5" />
</div>
</div>
{/* Video Type Badge */}
<div className="absolute top-2 right-2">
<div className="bg-black/70 backdrop-blur-sm rounded-full px-2 py-1">
<Film className="h-3 w-3 text-white" />
</div>
</div>
</div>
<CardContent className="p-4">
<h3 className="font-medium text-foreground text-sm line-clamp-2 mb-2 group-hover:text-primary transition-colors">
{video.title}
</h3>
{/* Star Rating */}
{(video.avg_rating > 0 || video.star_count > 0) && (
<div className="mb-2">
<StarRating
rating={video.avg_rating || 0}
count={video.star_count}
size="sm"
showCount={true}
/>
</div>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
<span>{formatFileSize(video.size)}</span>
</div>
</div>
<p
className="text-xs text-muted-foreground mt-2 line-clamp-1 cursor-help"
title={video.path}
>
{formatFilePath(video.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 videos 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">
<Film className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">No Videos Found</h3>
<p className="text-muted-foreground mb-6">Add media libraries and scan for videos to get started</p>
<Link href="/settings">
<Button>
<Film className="h-4 w-4 mr-2" />
Add Library
</Button>
</Link>
</div>
</div>
)}
</div>
</div>
{/* Video Viewer */}
<VideoViewer
video={selectedVideo!}
isOpen={isPlayerOpen}
onClose={handleClosePlayer}
showBookmarks={true}
showRatings={true}
formatFileSize={formatFileSize}
onBookmark={handleBookmark}
onUnbookmark={handleUnbookmark}
onRate={handleRate}
/>
</>
);
};
export default VideosPage;