224 lines
8.5 KiB
TypeScript
224 lines
8.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import Link from "next/link";
|
|
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";
|
|
|
|
interface Video {
|
|
id: number;
|
|
title: string;
|
|
path: string;
|
|
size: number;
|
|
thumbnail: string;
|
|
}
|
|
|
|
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 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);
|
|
};
|
|
|
|
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.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.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>
|
|
<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">
|
|
{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>
|
|
|
|
{/* Inline Video Player - Rendered as Portal */}
|
|
{selectedVideo && isPlayerOpen && typeof window !== 'undefined' && createPortal(
|
|
<InlineVideoPlayer
|
|
video={selectedVideo}
|
|
isOpen={isPlayerOpen}
|
|
onClose={handleClosePlayer}
|
|
scrollPosition={scrollPosition}
|
|
/>,
|
|
document.body
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default VideosPage; |