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:
tigeren 2025-08-26 06:56:40 +00:00
parent 95a49380da
commit 2864e30542
11 changed files with 393 additions and 1 deletions

View File

@ -12,6 +12,8 @@ Feature requirement:
7. there should be a database(sqlite) to save the media informations 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 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 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: UI:
@ -24,5 +26,6 @@ UI:
6. photo section: TBD 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 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. 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

View File

@ -12,6 +12,8 @@ Feature requirement:
7. there should be a database(sqlite) to save the media informations 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 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 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: UI:
@ -24,5 +26,6 @@ UI:
6. photo section: TBD 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 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. 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
View File

@ -12,6 +12,8 @@ Feature requirement:
7. there should be a database(sqlite) to save the media informations 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 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 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: UI:
@ -24,5 +26,6 @@ UI:
6. photo section: TBD 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 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. 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

BIN
media.db

Binary file not shown.

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useRef, useEffect } from 'react'; 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 { interface InlineVideoPlayerProps {
video: { video: {
@ -25,11 +25,19 @@ export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPositi
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [showControls, setShowControls] = useState(true); 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); const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setIsVisible(true); setIsVisible(true);
loadBookmarkStatus();
loadStarRating();
} else { } else {
setIsVisible(false); setIsVisible(false);
} }
@ -109,6 +117,82 @@ export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPositi
return `${minutes}:${seconds.toString().padStart(2, '0')}`; 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(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { 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"> <h1 className="text-xl font-semibold text-foreground truncate max-w-md">
{video.title} {video.title}
</h1> </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> </div>
</div> </div>

View File

@ -21,9 +21,39 @@ db.exec(`
title TEXT, title TEXT,
size INTEGER, size INTEGER,
thumbnail TEXT, thumbnail TEXT,
bookmark_count INTEGER DEFAULT 0,
star_count INTEGER DEFAULT 0,
avg_rating REAL DEFAULT 0.0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (library_id) REFERENCES libraries (id) 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; export default db;