feat: add bookmarking and star rating features to video player
- Implemented bookmark functionality allowing users to bookmark/unbookmark videos. - Added star rating feature enabling users to rate videos from one to five stars. - Updated database schema to include bookmarks and stars tables, along with necessary indexes for performance. - Enhanced inline video player UI to display bookmark status and average star rating, improving user interaction and feedback.
This commit is contained in:
parent
95a49380da
commit
2864e30542
|
|
@ -12,6 +12,8 @@ Feature requirement:
|
|||
7. there should be a database(sqlite) to save the media informations
|
||||
8. there should be at least two tables in the database, one to keep the video informations, such as path, size, thumbnails, title, etc(not limit to these); one to keep the media libraries. videos should be linked to the library
|
||||
9. thumbnail generate feature
|
||||
10. bookmark feature: the video can be bookmarked
|
||||
11. star feature: the video can be stared as one to five stars
|
||||
|
||||
|
||||
UI:
|
||||
|
|
@ -24,5 +26,6 @@ UI:
|
|||
6. photo section: TBD
|
||||
7. folder viewer: list libraries in the side bar folder viewer section. once one of the folder is selected, display the folder sturcture in the main area. the vdieo or photo display thunbnail and information as the video card would do. for the folder, display the folder icon, which can be entered in
|
||||
8. the video card can be clicked, once clicked, a poped up video player will be displayed. it can be closed, fast forward, expand to full screen, etc.
|
||||
9. can bookmark/un-bookmark the video, can star the video
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ Feature requirement:
|
|||
7. there should be a database(sqlite) to save the media informations
|
||||
8. there should be at least two tables in the database, one to keep the video informations, such as path, size, thumbnails, title, etc(not limit to these); one to keep the media libraries. videos should be linked to the library
|
||||
9. thumbnail generate feature
|
||||
10. bookmark feature: the video can be bookmarked
|
||||
11. star feature: the video can be stared as one to five stars
|
||||
|
||||
|
||||
UI:
|
||||
|
|
@ -24,5 +26,6 @@ UI:
|
|||
6. photo section: TBD
|
||||
7. folder viewer: list libraries in the side bar folder viewer section. once one of the folder is selected, display the folder sturcture in the main area. the vdieo or photo display thunbnail and information as the video card would do. for the folder, display the folder icon, which can be entered in
|
||||
8. the video card can be clicked, once clicked, a poped up video player will be displayed. it can be closed, fast forward, expand to full screen, etc.
|
||||
9. can bookmark/un-bookmark the video, can star the video
|
||||
|
||||
|
||||
|
|
|
|||
3
PRD.md
3
PRD.md
|
|
@ -12,6 +12,8 @@ Feature requirement:
|
|||
7. there should be a database(sqlite) to save the media informations
|
||||
8. there should be at least two tables in the database, one to keep the video informations, such as path, size, thumbnails, title, etc(not limit to these); one to keep the media libraries. videos should be linked to the library
|
||||
9. thumbnail generate feature
|
||||
10. bookmark feature: the video can be bookmarked
|
||||
11. star feature: the video can be stared as one to five stars
|
||||
|
||||
|
||||
UI:
|
||||
|
|
@ -24,5 +26,6 @@ UI:
|
|||
6. photo section: TBD
|
||||
7. folder viewer: list libraries in the side bar folder viewer section. once one of the folder is selected, display the folder sturcture in the main area. the vdieo or photo display thunbnail and information as the video card would do. for the folder, display the folder icon, which can be entered in
|
||||
8. the video card can be clicked, once clicked, a poped up video player will be displayed. it can be closed, fast forward, expand to full screen, etc.
|
||||
9. can bookmark/un-bookmark the video, can star the video
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import db from '@/db';
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const parsedId = parseInt(id);
|
||||
|
||||
if (isNaN(parsedId)) {
|
||||
return NextResponse.json({ error: 'Invalid bookmark ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get media_id before deleting
|
||||
const bookmark = db.prepare(`
|
||||
SELECT media_id FROM bookmarks WHERE id = ?
|
||||
`).get(parsedId) as { media_id: number } | undefined;
|
||||
|
||||
if (!bookmark) {
|
||||
return NextResponse.json({ error: 'Bookmark not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Delete bookmark
|
||||
db.prepare(`DELETE FROM bookmarks WHERE id = ?`).run(parsedId);
|
||||
|
||||
// Update media bookmark count
|
||||
db.prepare(`
|
||||
UPDATE media
|
||||
SET bookmark_count = (SELECT COUNT(*) FROM bookmarks WHERE media_id = ?)
|
||||
WHERE id = ?
|
||||
`).run(bookmark.media_id, bookmark.media_id);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import db from '@/db';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const bookmarks = db.prepare(`
|
||||
SELECT m.*, l.path as library_path
|
||||
FROM bookmarks b
|
||||
JOIN media m ON b.media_id = m.id
|
||||
JOIN libraries l ON m.library_id = l.id
|
||||
ORDER BY b.updated_at DESC
|
||||
`).all();
|
||||
|
||||
return NextResponse.json(bookmarks);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { mediaId } = await request.json();
|
||||
|
||||
if (!mediaId) {
|
||||
return NextResponse.json({ error: 'mediaId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if already bookmarked
|
||||
const existing = db.prepare(`
|
||||
SELECT id FROM bookmarks WHERE media_id = ?
|
||||
`).get(mediaId);
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'Already bookmarked' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Insert bookmark
|
||||
const result = db.prepare(`
|
||||
INSERT INTO bookmarks (media_id) VALUES (?)
|
||||
`).run(mediaId);
|
||||
|
||||
// Update media bookmark count
|
||||
db.prepare(`
|
||||
UPDATE media
|
||||
SET bookmark_count = (SELECT COUNT(*) FROM bookmarks WHERE media_id = ?)
|
||||
WHERE id = ?
|
||||
`).run(mediaId, mediaId);
|
||||
|
||||
return NextResponse.json({ id: result.lastInsertRowid });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import db from '@/db';
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const parsedId = parseInt(id);
|
||||
|
||||
if (isNaN(parsedId)) {
|
||||
return NextResponse.json({ error: 'Invalid star ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get media_id before deleting
|
||||
const star = db.prepare(`
|
||||
SELECT media_id FROM stars WHERE id = ?
|
||||
`).get(parsedId) as { media_id: number } | undefined;
|
||||
|
||||
if (!star) {
|
||||
return NextResponse.json({ error: 'Star rating not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Delete star rating
|
||||
db.prepare(`DELETE FROM stars WHERE id = ?`).run(parsedId);
|
||||
|
||||
// Update media star count and average rating
|
||||
db.prepare(`
|
||||
UPDATE media
|
||||
SET
|
||||
star_count = (SELECT COUNT(*) FROM stars WHERE media_id = ?),
|
||||
avg_rating = COALESCE((SELECT AVG(rating) FROM stars WHERE media_id = ?), 0.0)
|
||||
WHERE id = ?
|
||||
`).run(star.media_id, star.media_id, star.media_id);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import db from '@/db';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const mediaId = searchParams.get('mediaId');
|
||||
|
||||
if (mediaId) {
|
||||
// Get stars for specific media
|
||||
const stars = db.prepare(`
|
||||
SELECT * FROM stars WHERE media_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`).all(parseInt(mediaId));
|
||||
|
||||
return NextResponse.json(stars);
|
||||
} else {
|
||||
// Get all stars
|
||||
const stars = db.prepare(`
|
||||
SELECT m.*, l.path as library_path, s.rating
|
||||
FROM stars s
|
||||
JOIN media m ON s.media_id = m.id
|
||||
JOIN libraries l ON m.library_id = l.id
|
||||
ORDER BY s.created_at DESC
|
||||
`).all();
|
||||
|
||||
return NextResponse.json(stars);
|
||||
}
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { mediaId, rating } = await request.json();
|
||||
|
||||
if (!mediaId || !rating) {
|
||||
return NextResponse.json({ error: 'mediaId and rating are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (rating < 1 || rating > 5) {
|
||||
return NextResponse.json({ error: 'Rating must be between 1 and 5' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if already starred
|
||||
const existing = db.prepare(`
|
||||
SELECT id FROM stars WHERE media_id = ?
|
||||
`).get(mediaId);
|
||||
|
||||
if (existing) {
|
||||
// Update existing star rating
|
||||
db.prepare(`
|
||||
UPDATE stars
|
||||
SET rating = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE media_id = ?
|
||||
`).run(rating, mediaId);
|
||||
} else {
|
||||
// Insert new star rating
|
||||
db.prepare(`
|
||||
INSERT INTO stars (media_id, rating) VALUES (?, ?)
|
||||
`).run(mediaId, rating);
|
||||
}
|
||||
|
||||
// Update media star count and average rating
|
||||
db.prepare(`
|
||||
UPDATE media
|
||||
SET
|
||||
star_count = (SELECT COUNT(*) FROM stars WHERE media_id = ?),
|
||||
avg_rating = (SELECT AVG(rating) FROM stars WHERE media_id = ?)
|
||||
WHERE id = ?
|
||||
`).run(mediaId, mediaId, mediaId);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import db from '@/db';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const parsedId = parseInt(id);
|
||||
|
||||
if (isNaN(parsedId)) {
|
||||
return NextResponse.json({ error: 'Invalid video ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const video = db.prepare(`
|
||||
SELECT m.*, l.path as library_path
|
||||
FROM media m
|
||||
JOIN libraries l ON m.library_id = l.id
|
||||
WHERE m.id = ?
|
||||
`).get(parsedId);
|
||||
|
||||
if (!video) {
|
||||
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(video);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { X, Play, Pause, Maximize, Minimize, Volume2, VolumeX } from 'lucide-react';
|
||||
import { X, Play, Pause, Maximize, Minimize, Volume2, VolumeX, Bookmark, Star, Heart } from 'lucide-react';
|
||||
|
||||
interface InlineVideoPlayerProps {
|
||||
video: {
|
||||
|
|
@ -25,11 +25,19 @@ export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPositi
|
|||
const [duration, setDuration] = useState(0);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||
const [userRating, setUserRating] = useState(0);
|
||||
const [avgRating, setAvgRating] = useState(0);
|
||||
const [bookmarkCount, setBookmarkCount] = useState(0);
|
||||
const [starCount, setStarCount] = useState(0);
|
||||
const [showRating, setShowRating] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
loadBookmarkStatus();
|
||||
loadStarRating();
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
|
|
@ -109,6 +117,82 @@ export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPositi
|
|||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const loadBookmarkStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/bookmarks?mediaId=${video.id}`);
|
||||
const data = await response.json();
|
||||
setIsBookmarked(data.length > 0);
|
||||
} catch (error) {
|
||||
console.error('Error loading bookmark status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStarRating = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/stars?mediaId=${video.id}`);
|
||||
const stars = await response.json();
|
||||
|
||||
// Get media info for counts and avg rating
|
||||
const mediaResponse = await fetch(`/api/videos/${video.id}`);
|
||||
const mediaData = await mediaResponse.json();
|
||||
|
||||
setBookmarkCount(mediaData.bookmark_count || 0);
|
||||
setStarCount(mediaData.star_count || 0);
|
||||
setAvgRating(mediaData.avg_rating || 0);
|
||||
|
||||
// Set user's rating if exists
|
||||
if (stars.length > 0) {
|
||||
setUserRating(stars[0].rating);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading star rating:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBookmark = async () => {
|
||||
try {
|
||||
if (isBookmarked) {
|
||||
// Remove bookmark
|
||||
const response = await fetch(`/api/bookmarks/${video.id}`, { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
setIsBookmarked(false);
|
||||
setBookmarkCount(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
} else {
|
||||
// Add bookmark
|
||||
const response = await fetch('/api/bookmarks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mediaId: video.id })
|
||||
});
|
||||
if (response.ok) {
|
||||
setIsBookmarked(true);
|
||||
setBookmarkCount(prev => prev + 1);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling bookmark:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStarClick = async (rating: number) => {
|
||||
try {
|
||||
const response = await fetch('/api/stars', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mediaId: video.id, rating })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setUserRating(rating);
|
||||
// Reload star data
|
||||
await loadStarRating();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting star rating:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
|
|
@ -153,6 +237,42 @@ export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPositi
|
|||
<h1 className="text-xl font-semibold text-foreground truncate max-w-md">
|
||||
{video.title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Bookmark Button */}
|
||||
<button
|
||||
onClick={toggleBookmark}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full transition-colors ${
|
||||
isBookmarked
|
||||
? 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
: 'bg-muted hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
<Bookmark className={`h-4 w-4 ${isBookmarked ? 'fill-current' : ''}`} />
|
||||
<span className="text-sm">{bookmarkCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Star Rating */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => handleStarClick(star)}
|
||||
className="text-yellow-500 hover:text-yellow-400 transition-colors"
|
||||
>
|
||||
<Star
|
||||
className={`h-4 w-4 ${
|
||||
star <= userRating ? 'fill-current' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{avgRating.toFixed(1)} ({starCount})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,9 +21,39 @@ db.exec(`
|
|||
title TEXT,
|
||||
size INTEGER,
|
||||
thumbnail TEXT,
|
||||
bookmark_count INTEGER DEFAULT 0,
|
||||
star_count INTEGER DEFAULT 0,
|
||||
avg_rating REAL DEFAULT 0.0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (library_id) REFERENCES libraries (id)
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
media_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS stars (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
media_id INTEGER NOT NULL,
|
||||
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Create indexes for performance
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_media_id ON bookmarks(media_id);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_stars_media_id ON stars(media_id);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_bookmark_count ON media(bookmark_count);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_star_count ON media(star_count);`);
|
||||
|
||||
export default db;
|
||||
|
|
|
|||
Loading…
Reference in New Issue