Compare commits
4 Commits
555a71ffc6
...
6f938243ad
| Author | SHA1 | Date |
|---|---|---|
|
|
6f938243ad | |
|
|
a752ce964a | |
|
|
5014434717 | |
|
|
50deee7f2a |
|
|
@ -39,3 +39,5 @@ yarn-error.log*
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
public/thumbnails
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
Project Description:
|
Project Description:
|
||||||
This is a nextjs project, basically a youtube like video sites.
|
This is a nextjs project, basically a youtube like video sites.
|
||||||
|
the tailwindcss is v3 version. must ensure all the css related tailwind sytling code should comply with the v3 standard
|
||||||
|
|
||||||
Feature requirement:
|
Feature requirement:
|
||||||
1. Has a youtube like UI
|
1. Has a youtube like UI
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
Project Description:
|
Project Description:
|
||||||
This is a nextjs project, basically a youtube like video sites.
|
This is a nextjs project, basically a youtube like video sites.
|
||||||
|
the tailwindcss is v3 version. must ensure all the css related tailwind sytling code should comply with the v3 standard
|
||||||
|
|
||||||
Feature requirement:
|
Feature requirement:
|
||||||
1. Has a youtube like UI
|
1. Has a youtube like UI
|
||||||
|
|
|
||||||
2
PRD.md
|
|
@ -1,6 +1,6 @@
|
||||||
Project Description:
|
Project Description:
|
||||||
This is a nextjs project, basically a youtube like video sites.
|
This is a nextjs project, basically a youtube like video sites.
|
||||||
the tailwindcss is v4 version. must ensure all the css related tailwind sytling code should comply with the v4 standard
|
the tailwindcss is v3 version. must ensure all the css related tailwind sytling code should comply with the v3 standard
|
||||||
|
|
||||||
Feature requirement:
|
Feature requirement:
|
||||||
1. Has a youtube like UI
|
1. Has a youtube like UI
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240" viewBox="0 0 320 240" fill="none">
|
||||||
|
<rect width="320" height="240" fill="#1f2937"/>
|
||||||
|
<rect x="90" y="80" width="140" height="80" rx="4" stroke="#6b7280" stroke-width="2" fill="none"/>
|
||||||
|
<circle cx="160" cy="120" r="25" stroke="#6b7280" stroke-width="2" fill="none"/>
|
||||||
|
<circle cx="160" cy="120" r="12" stroke="#6b7280" stroke-width="1" fill="none"/>
|
||||||
|
<text x="160" y="180" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#9ca3af">Photo</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 543 B |
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240" viewBox="0 0 320 240" fill="none">
|
||||||
|
<rect width="320" height="240" fill="#1f2937"/>
|
||||||
|
<circle cx="160" cy="120" r="40" stroke="#6b7280" stroke-width="2" fill="none"/>
|
||||||
|
<polygon points="140,100 180,120 140,140" fill="#6b7280"/>
|
||||||
|
<text x="160" y="180" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#9ca3af">Video</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 420 B |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 443 B |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 383 B |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 2.8 MiB |
|
|
@ -0,0 +1,63 @@
|
||||||
|
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 videoId = parseInt(id);
|
||||||
|
|
||||||
|
const video = db.prepare("SELECT * FROM media WHERE id = ? AND type = 'video'").get(videoId) as { path: string } | undefined;
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return NextResponse.json({ error: "Video not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoPath = video.path;
|
||||||
|
|
||||||
|
if (!fs.existsSync(videoPath)) {
|
||||||
|
return NextResponse.json({ error: "Video file not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(videoPath);
|
||||||
|
const fileSize = stat.size;
|
||||||
|
const range = request.headers.get("range");
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
const parts = range.replace(/bytes=/, "").split("-");
|
||||||
|
const start = parseInt(parts[0], 10);
|
||||||
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||||
|
const chunksize = end - start + 1;
|
||||||
|
const file = fs.createReadStream(videoPath, { start, end });
|
||||||
|
const headers = new Headers({
|
||||||
|
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": chunksize.toString(),
|
||||||
|
"Content-Type": "video/mp4",
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(file as any, {
|
||||||
|
status: 206,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const headers = new Headers({
|
||||||
|
"Content-Length": fileSize.toString(),
|
||||||
|
"Content-Type": "video/mp4",
|
||||||
|
});
|
||||||
|
const file = fs.createReadStream(videoPath);
|
||||||
|
|
||||||
|
return new Response(file as any, {
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error streaming video:", error);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
@ -7,6 +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 InlineVideoPlayer from "@/components/inline-video-player";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
interface Video {
|
interface Video {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -20,6 +21,9 @@ const VideosPage = () => {
|
||||||
const [videos, setVideos] = useState<Video[]>([]);
|
const [videos, setVideos] = useState<Video[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
|
||||||
|
const [scrollPosition, setScrollPosition] = useState(0);
|
||||||
|
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchVideos();
|
fetchVideos();
|
||||||
|
|
@ -50,6 +54,16 @@ const VideosPage = () => {
|
||||||
video.path.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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-6">
|
<div className="min-h-screen p-6">
|
||||||
|
|
@ -68,8 +82,9 @@ const VideosPage = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-6">
|
<>
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="min-h-screen p-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
|
@ -108,7 +123,11 @@ const VideosPage = () => {
|
||||||
{filteredVideos.length > 0 ? (
|
{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">
|
<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) => (
|
{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">
|
<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">
|
<div className="aspect-video relative overflow-hidden bg-muted">
|
||||||
<img
|
<img
|
||||||
src={video.thumbnail || "/placeholder.svg"}
|
src={video.thumbnail || "/placeholder.svg"}
|
||||||
|
|
@ -158,12 +177,8 @@ const VideosPage = () => {
|
||||||
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
|
<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" />
|
<Search className="h-8 w-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-foreground mb-2">
|
<h3 className="text-xl font-semibold text-foreground mb-2">No videos found</h3>
|
||||||
No videos found
|
<p className="text-muted-foreground mb-4">Try adjusting your search terms</p>
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground mb-4">
|
|
||||||
Try adjusting your search terms
|
|
||||||
</p>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setSearchTerm("")}
|
onClick={() => setSearchTerm("")}
|
||||||
|
|
@ -178,12 +193,8 @@ const VideosPage = () => {
|
||||||
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
|
<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" />
|
<Film className="h-8 w-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-foreground mb-2">
|
<h3 className="text-xl font-semibold text-foreground mb-2">No Videos Found</h3>
|
||||||
No Videos Found
|
<p className="text-muted-foreground mb-6">Add media libraries and scan for videos to get started</p>
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground mb-6">
|
|
||||||
Add media libraries and scan for videos to get started
|
|
||||||
</p>
|
|
||||||
<Link href="/settings">
|
<Link href="/settings">
|
||||||
<Button>
|
<Button>
|
||||||
<Film className="h-4 w-4 mr-2" />
|
<Film className="h-4 w-4 mr-2" />
|
||||||
|
|
@ -195,7 +206,19 @@ const VideosPage = () => {
|
||||||
)}
|
)}
|
||||||
</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;
|
export default VideosPage;
|
||||||
|
|
@ -0,0 +1,258 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { X, Play, Pause, Maximize, Minimize, Volume2, VolumeX } from 'lucide-react';
|
||||||
|
|
||||||
|
interface InlineVideoPlayerProps {
|
||||||
|
video: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
thumbnail: string;
|
||||||
|
};
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
scrollPosition?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPosition }: InlineVideoPlayerProps) {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [showControls, setShowControls] = useState(true);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setIsVisible(true);
|
||||||
|
} else {
|
||||||
|
setIsVisible(false);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && videoRef.current) {
|
||||||
|
videoRef.current.src = `/api/stream/${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);
|
||||||
|
// Auto-play might be blocked by browser, that's okay
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isOpen, video.id]);
|
||||||
|
|
||||||
|
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>) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
const newVolume = parseFloat(e.target.value);
|
||||||
|
videoRef.current.volume = newVolume;
|
||||||
|
setVolume(newVolume);
|
||||||
|
setIsMuted(newVolume === 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
setCurrentTime(videoRef.current.currentTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
setDuration(videoRef.current.duration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const clickX = e.clientX - rect.left;
|
||||||
|
const newTime = (clickX / rect.width) * duration;
|
||||||
|
videoRef.current.currentTime = newTime;
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (time: number) => {
|
||||||
|
const minutes = Math.floor(time / 60);
|
||||||
|
const seconds = Math.floor(time % 60);
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
if (e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handlePlayPause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
// Prevent body scroll when player is open
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
// Restore body scroll when player is closed
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`fixed inset-0 z-50 bg-background transition-opacity duration-300 ${isVisible ? 'opacity-100' : 'opacity-0'}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 z-10 bg-background border-b border-border">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
<span>Back to videos</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-foreground truncate max-w-md">
|
||||||
|
{video.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Player */}
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||||
|
<div className="aspect-video bg-black rounded-lg overflow-hidden relative group">
|
||||||
|
<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)}
|
||||||
|
controls={false}
|
||||||
|
>
|
||||||
|
<source src={`/api/stream/${video.id}`} type="video/mp4" />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
|
||||||
|
{/* Video Overlay Controls */}
|
||||||
|
<div className={`absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
||||||
|
{/* Center Play Button */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
className="w-20 h-20 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white/30 transition-all duration-200"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause className="h-8 w-8 text-white" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-8 w-8 text-white ml-1" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Controls */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div
|
||||||
|
className="relative h-2 bg-white/20 rounded-full cursor-pointer mb-4"
|
||||||
|
onClick={handleProgressClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-white rounded-full"
|
||||||
|
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Control Buttons */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
className="p-2 hover:bg-white/20 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-5 w-5 text-white" /> : <Play className="h-5 w-5 text-white" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleMute}
|
||||||
|
className="p-2 hover:bg-white/20 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
{isMuted ? <VolumeX className="h-5 w-5 text-white" /> : <Volume2 className="h-5 w-5 text-white" />}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={volume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="w-24 h-1 bg-white/20 rounded-full appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-sm text-white">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-sm text-white">
|
||||||
|
{Math.round(video.size / 1024 / 1024)} MB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video info below player */}
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">{video.title}</h2>
|
||||||
|
<p className="text-muted-foreground font-mono text-sm break-all">
|
||||||
|
{video.path}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { X, Play, Pause, Maximize, Minimize, Volume2, VolumeX } from 'lucide-react';
|
||||||
|
|
||||||
|
interface VideoPlayerProps {
|
||||||
|
video: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
thumbnail: string;
|
||||||
|
};
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoPlayer({ video, isOpen, onClose }: VideoPlayerProps) {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && videoRef.current) {
|
||||||
|
videoRef.current.src = `/api/stream/${video.id}`;
|
||||||
|
videoRef.current.load();
|
||||||
|
}
|
||||||
|
}, [isOpen, video.id]);
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
if (isPlaying) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
} else {
|
||||||
|
videoRef.current.play();
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFullscreen = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
if (!isFullscreen) {
|
||||||
|
videoRef.current.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMute = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.muted = !isMuted;
|
||||||
|
setIsMuted(!isMuted);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
const newVolume = parseFloat(e.target.value);
|
||||||
|
videoRef.current.volume = newVolume;
|
||||||
|
setVolume(newVolume);
|
||||||
|
setIsMuted(newVolume === 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
setCurrentTime(videoRef.current.currentTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
setDuration(videoRef.current.duration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const clickX = e.clientX - rect.left;
|
||||||
|
const newTime = (clickX / rect.width) * duration;
|
||||||
|
videoRef.current.currentTime = newTime;
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (time: number) => {
|
||||||
|
const minutes = Math.floor(time / 60);
|
||||||
|
const seconds = Math.floor(time % 60);
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
if (e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handlePlayPause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onPlay={() => setIsPlaying(true)}
|
||||||
|
onPause={() => setIsPlaying(false)}
|
||||||
|
>
|
||||||
|
<source src={`/api/stream/${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">
|
||||||
|
<h2 className="text-white text-lg font-semibold">{video.title}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls overlay */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent">
|
||||||
|
<div className="p-4 space-y-2">
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div
|
||||||
|
className="relative h-1 bg-white/20 rounded-full cursor-pointer"
|
||||||
|
onClick={handleProgressClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-white rounded-full"
|
||||||
|
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center justify-between text-white">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={handleMute}
|
||||||
|
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
{isMuted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={volume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="w-20 h-1 bg-white/20 rounded-full appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-sm">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleFullscreen}
|
||||||
|
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
{isFullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -70,10 +70,21 @@ const scanLibrary = async (library: { id: number; path: string }) => {
|
||||||
fs.mkdirSync(thumbnailsDir, { recursive: true });
|
fs.mkdirSync(thumbnailsDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVideo) {
|
let finalThumbnailUrl = thumbnailUrl;
|
||||||
await generateVideoThumbnail(file, thumbnailPath);
|
let thumbnailGenerated = false;
|
||||||
} else if (isPhoto) {
|
|
||||||
await generatePhotoThumbnail(file, thumbnailPath);
|
try {
|
||||||
|
if (isVideo) {
|
||||||
|
await generateVideoThumbnail(file, thumbnailPath);
|
||||||
|
thumbnailGenerated = true;
|
||||||
|
} else if (isPhoto) {
|
||||||
|
await generatePhotoThumbnail(file, thumbnailPath);
|
||||||
|
thumbnailGenerated = true;
|
||||||
|
}
|
||||||
|
} catch (thumbnailError) {
|
||||||
|
console.warn(`Thumbnail generation failed for ${file}:`, thumbnailError);
|
||||||
|
// Use fallback thumbnail based on media type
|
||||||
|
finalThumbnailUrl = isVideo ? "/placeholder-video.svg" : "/placeholder-photo.svg";
|
||||||
}
|
}
|
||||||
|
|
||||||
const media = {
|
const media = {
|
||||||
|
|
@ -82,12 +93,14 @@ const scanLibrary = async (library: { id: number; path: string }) => {
|
||||||
type: mediaType,
|
type: mediaType,
|
||||||
title: title,
|
title: title,
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
thumbnail: thumbnailUrl,
|
thumbnail: finalThumbnailUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT INTO media (library_id, path, type, title, size, thumbnail) VALUES (?, ?, ?, ?, ?, ?)"
|
"INSERT INTO media (library_id, path, type, title, size, thumbnail) VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
).run(media.library_id, media.path, media.type, media.title, media.size, media.thumbnail);
|
).run(media.library_id, media.path, media.type, media.title, media.size, media.thumbnail);
|
||||||
|
|
||||||
|
console.log(`Successfully inserted ${mediaType}: ${title}${thumbnailGenerated ? ' with thumbnail' : ' with fallback thumbnail'}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.code !== "SQLITE_CONSTRAINT_UNIQUE") {
|
if (error.code !== "SQLITE_CONSTRAINT_UNIQUE") {
|
||||||
console.error(`Error inserting media: ${file}`, error);
|
console.error(`Error inserting media: ${file}`, error);
|
||||||
|
|
|
||||||