feat(media): add bookmark feature and rating improvements
- Extend media database query to include bookmark count with LEFT JOINs - Add bookmark_count property to media file types and UI components - Implement bookmark add and remove handlers with API calls - Enhance video player overlay to show bookmark button and rating stars - Update ArtPlayerWrapper for interactive 5-star rating and bookmark toggling - Modify folder viewer and virtualized media grid to display bookmark counts - Simplify video player debug to always use ArtPlayer and show static support info
This commit is contained in:
parent
ced8125224
commit
74980b5059
|
|
@ -25,10 +25,16 @@ export async function GET(request: Request) {
|
||||||
|
|
||||||
// Get media files from database for this path
|
// Get media files from database for this path
|
||||||
const mediaFiles = db.prepare(`
|
const mediaFiles = db.prepare(`
|
||||||
SELECT id, path, type, thumbnail, avg_rating, star_count
|
SELECT m.id, m.path, m.type, m.thumbnail,
|
||||||
FROM media
|
COALESCE(AVG(s.rating), 0) as avg_rating,
|
||||||
WHERE path LIKE ?
|
COUNT(s.id) as star_count,
|
||||||
`).all(`${decodedPath}%`) as Array<{id: number, path: string, type: string, thumbnail: string | null, avg_rating: number, star_count: number}>;
|
COUNT(b.id) as bookmark_count
|
||||||
|
FROM media m
|
||||||
|
LEFT JOIN stars s ON m.id = s.media_id
|
||||||
|
LEFT JOIN bookmarks b ON m.id = b.media_id
|
||||||
|
WHERE m.path LIKE ?
|
||||||
|
GROUP BY m.id, m.path, m.type, m.thumbnail
|
||||||
|
`).all(`${decodedPath}%`) as Array<{id: number, path: string, type: string, thumbnail: string | null, avg_rating: number, star_count: number, bookmark_count: number}>;
|
||||||
|
|
||||||
const result = files.map((file) => {
|
const result = files.map((file) => {
|
||||||
const filePath = path.join(decodedPath, file);
|
const filePath = path.join(decodedPath, file);
|
||||||
|
|
@ -58,6 +64,7 @@ export async function GET(request: Request) {
|
||||||
id: mediaFile?.id,
|
id: mediaFile?.id,
|
||||||
avg_rating: mediaFile?.avg_rating || 0,
|
avg_rating: mediaFile?.avg_rating || 0,
|
||||||
star_count: mediaFile?.star_count || 0,
|
star_count: mediaFile?.star_count || 0,
|
||||||
|
bookmark_count: mediaFile?.bookmark_count || 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ interface FileSystemItem {
|
||||||
id?: number;
|
id?: number;
|
||||||
avg_rating?: number;
|
avg_rating?: number;
|
||||||
star_count?: number;
|
star_count?: number;
|
||||||
|
bookmark_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BreadcrumbItem {
|
interface BreadcrumbItem {
|
||||||
|
|
@ -176,6 +177,60 @@ const FolderViewerPage = () => {
|
||||||
setSelectedText(null);
|
setSelectedText(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle bookmark operations
|
||||||
|
const handleBookmark = async (mediaId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/bookmarks/${mediaId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('Bookmark added successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error bookmarking item:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnbookmark = async (mediaId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/bookmarks/${mediaId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('Bookmark removed successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error unbookmarking item:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle rating operations
|
||||||
|
const handleRate = async (mediaId: number, rating: number) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/stars/${mediaId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ rating })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('Rating added successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rating item:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [currentItems, setCurrentItems] = useState<FileSystemItem[]>([]);
|
const [currentItems, setCurrentItems] = useState<FileSystemItem[]>([]);
|
||||||
|
|
||||||
// Custom Text Viewer Component for files without IDs
|
// Custom Text Viewer Component for files without IDs
|
||||||
|
|
@ -407,9 +462,12 @@ const FolderViewerPage = () => {
|
||||||
onNext={handleNextPhoto}
|
onNext={handleNextPhoto}
|
||||||
onPrev={handlePrevPhoto}
|
onPrev={handlePrevPhoto}
|
||||||
showNavigation={true}
|
showNavigation={true}
|
||||||
showBookmarks={false}
|
showBookmarks={true}
|
||||||
showRatings={false}
|
showRatings={true}
|
||||||
formatFileSize={formatFileSize}
|
formatFileSize={formatFileSize}
|
||||||
|
onBookmark={handleBookmark}
|
||||||
|
onUnbookmark={handleUnbookmark}
|
||||||
|
onRate={handleRate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Video Player */}
|
{/* Video Player */}
|
||||||
|
|
@ -422,7 +480,7 @@ const FolderViewerPage = () => {
|
||||||
size: selectedVideo.size,
|
size: selectedVideo.size,
|
||||||
thumbnail: selectedVideo.thumbnail || '',
|
thumbnail: selectedVideo.thumbnail || '',
|
||||||
type: selectedVideo.type || 'video',
|
type: selectedVideo.type || 'video',
|
||||||
bookmark_count: selectedVideo.star_count || 0,
|
bookmark_count: selectedVideo.bookmark_count || 0,
|
||||||
star_count: selectedVideo.star_count || 0,
|
star_count: selectedVideo.star_count || 0,
|
||||||
avg_rating: selectedVideo.avg_rating || 0
|
avg_rating: selectedVideo.avg_rating || 0
|
||||||
}}
|
}}
|
||||||
|
|
@ -430,9 +488,12 @@ const FolderViewerPage = () => {
|
||||||
onClose={handleClosePlayer}
|
onClose={handleClosePlayer}
|
||||||
playerType="modal"
|
playerType="modal"
|
||||||
useArtPlayer={true} // Force ArtPlayer for testing
|
useArtPlayer={true} // Force ArtPlayer for testing
|
||||||
showBookmarks={false}
|
showBookmarks={true}
|
||||||
showRatings={false}
|
showRatings={true}
|
||||||
formatFileSize={formatFileSize}
|
formatFileSize={formatFileSize}
|
||||||
|
onBookmark={handleBookmark}
|
||||||
|
onUnbookmark={handleUnbookmark}
|
||||||
|
onRate={handleRate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -424,6 +424,14 @@ export default function ArtPlayerWrapper({
|
||||||
setLocalAvgRating(newRating);
|
setLocalAvgRating(newRating);
|
||||||
}, [video.id, localAvgRating, onRate]);
|
}, [video.id, localAvgRating, onRate]);
|
||||||
|
|
||||||
|
// Handle individual star click for rating
|
||||||
|
const handleStarClick = useCallback((rating: number) => {
|
||||||
|
if (onRate) {
|
||||||
|
onRate(video.id, rating);
|
||||||
|
setLocalAvgRating(rating);
|
||||||
|
}
|
||||||
|
}, [onRate, video.id]);
|
||||||
|
|
||||||
// Format time display
|
// Format time display
|
||||||
const formatTime = (time: number) => {
|
const formatTime = (time: number) => {
|
||||||
const minutes = Math.floor(time / 60);
|
const minutes = Math.floor(time / 60);
|
||||||
|
|
@ -613,115 +621,70 @@ export default function ArtPlayerWrapper({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Video info overlay */}
|
{/* Video info overlay - Positioned on top of video with high transparency */}
|
||||||
<div className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
|
<div className={`absolute bottom-20 left-4 right-4 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'} pointer-events-none z-10`}>
|
||||||
<div className="bg-black/70 backdrop-blur-sm rounded-lg p-4 mb-2">
|
<div className="bg-black/10 backdrop-blur-sm rounded-lg px-4 py-3 pointer-events-auto">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-end justify-between">
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h3 className="text-white font-medium">{video.title}</h3>
|
<h3 className="text-white font-medium text-lg mb-1 drop-shadow-lg">{video.title}</h3>
|
||||||
<p className="text-gray-300 text-sm">{formatFileSize(video.size)}</p>
|
<div className="flex items-center gap-4 text-sm text-gray-200">
|
||||||
{duration > 0 && (
|
<span className="drop-shadow-md">{formatFileSize(video.size)}</span>
|
||||||
<p className="text-gray-300 text-sm">
|
{duration > 0 && (
|
||||||
Duration: {formatTime(duration)}
|
<span className="drop-shadow-md">
|
||||||
{format?.type === 'hls' && <span className="text-green-400 ml-1">(HLS)</span>}
|
Duration: {formatTime(duration)}
|
||||||
</p>
|
{format?.type === 'hls' && <span className="text-green-300 ml-1">(HLS)</span>}
|
||||||
)}
|
</span>
|
||||||
</div>
|
)}
|
||||||
{(showBookmarks || showRatings) && (
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{showBookmarks && (
|
|
||||||
<button
|
|
||||||
onClick={handleBookmarkToggle}
|
|
||||||
className={`flex items-center gap-1 text-white hover:text-yellow-400 transition-colors ${localIsBookmarked ? 'text-yellow-400' : ''}`}
|
|
||||||
>
|
|
||||||
<Bookmark className={`h-4 w-4 ${localIsBookmarked ? 'fill-current' : ''}`} />
|
|
||||||
<span className="text-xs">{localBookmarkCount}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{showRatings && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={handleRatingClick}
|
|
||||||
className="text-white hover:text-yellow-400 transition-colors"
|
|
||||||
>
|
|
||||||
<Star className={`h-4 w-4 ${localAvgRating > 0 ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-gray-300">{localAvgRating.toFixed(1)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
{/* 5-Star Rating System - Always show if enabled, regardless of current values */}
|
||||||
<div className="flex items-center justify-between">
|
{(showBookmarks || showRatings) && video.id && video.id > 0 && (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-6">
|
||||||
<button
|
{showBookmarks && (
|
||||||
onClick={() => {
|
<button
|
||||||
if (playerRef.current) {
|
onClick={handleBookmarkToggle}
|
||||||
if (isPlaying) {
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-full transition-all duration-200 backdrop-blur-sm ${
|
||||||
playerRef.current.pause();
|
localIsBookmarked
|
||||||
} else {
|
? 'bg-yellow-500/30 text-yellow-300 hover:bg-yellow-500/40'
|
||||||
playerRef.current.play();
|
: 'bg-white/20 text-white hover:bg-white/30'
|
||||||
}
|
}`}
|
||||||
}
|
title="Toggle bookmark"
|
||||||
}}
|
>
|
||||||
className="text-white hover:text-gray-300 transition-colors"
|
<Bookmark className={`h-4 w-4 ${localIsBookmarked ? 'fill-current' : ''} drop-shadow-md`} />
|
||||||
>
|
<span className="text-sm font-medium drop-shadow-md">{localBookmarkCount}</span>
|
||||||
{isPlaying ? (
|
</button>
|
||||||
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
|
)}
|
||||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
|
||||||
</svg>
|
{showRatings && (
|
||||||
) : (
|
<div className="flex items-center gap-2">
|
||||||
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
|
<div className="flex items-center gap-1">
|
||||||
<path d="M8 5v14l11-7z"/>
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
</svg>
|
<button
|
||||||
|
key={star}
|
||||||
|
onClick={() => handleStarClick(star)}
|
||||||
|
className="group transition-transform duration-150 hover:scale-110"
|
||||||
|
title={`Rate ${star} star${star !== 1 ? 's' : ''}`}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={`h-5 w-5 transition-colors duration-200 drop-shadow-md ${
|
||||||
|
star <= Math.round(localAvgRating)
|
||||||
|
? 'fill-yellow-300 text-yellow-300'
|
||||||
|
: 'text-gray-300 hover:text-yellow-200 group-hover:text-yellow-200'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-200 ml-1 drop-shadow-md" title="Current rating">
|
||||||
|
{localAvgRating > 0 ? localAvgRating.toFixed(1) : '0.0'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (playerRef.current) {
|
|
||||||
playerRef.current.muted = !isMuted;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-white hover:text-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
{isMuted ? (
|
|
||||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-gray-300">{Math.round(volume * 100)}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-sm text-white">
|
|
||||||
{formatTime(currentTime)} / {formatTime(duration)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (playerRef.current) {
|
|
||||||
playerRef.current.fullscreen = !playerRef.current.fullscreen;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-white hover:text-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,7 @@ export default function VideoPlayerDebug({ video, className = '' }: VideoPlayerD
|
||||||
<div>Format: {debugInfo.format?.supportLevel || 'unknown'}</div>
|
<div>Format: {debugInfo.format?.supportLevel || 'unknown'}</div>
|
||||||
<div>Reason: {debugInfo.reason}</div>
|
<div>Reason: {debugInfo.reason}</div>
|
||||||
<div className="text-gray-300">
|
<div className="text-gray-300">
|
||||||
ArtPlayer: {debugInfo.featureFlags?.enableArtPlayer ? '✅' : '❌'} |
|
ArtPlayer: ✅ (Always Active) | HLS: ✅ (Supported)
|
||||||
HLS: {debugInfo.featureFlags?.enableHLS ? '✅' : '❌'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -176,12 +175,13 @@ export async function testArtPlayerIntegration(): Promise<{
|
||||||
};
|
};
|
||||||
|
|
||||||
const format = detectVideoFormat(testVideo);
|
const format = detectVideoFormat(testVideo);
|
||||||
const shouldUse = shouldUseArtPlayer('test-user', '1', 'mp4');
|
// Always use ArtPlayer since it's the only player now
|
||||||
|
const shouldUse = true;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
playerType: shouldUse ? 'artplayer' : 'current',
|
playerType: 'artplayer',
|
||||||
error: shouldUse ? undefined : 'ArtPlayer not enabled for this user/video'
|
error: undefined
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ interface FileSystemItem {
|
||||||
id?: number;
|
id?: number;
|
||||||
avg_rating?: number;
|
avg_rating?: number;
|
||||||
star_count?: number;
|
star_count?: number;
|
||||||
|
bookmark_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BreadcrumbItem {
|
interface BreadcrumbItem {
|
||||||
|
|
@ -296,14 +297,24 @@ export default function VirtualizedFolderGrid({
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs mb-1">
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
<span className="text-slate-600 dark:text-slate-400">{formatFileSize(item.size)}</span>
|
<span className="text-slate-600 dark:text-slate-400">{formatFileSize(item.size)}</span>
|
||||||
{isMediaFile(item) && (item.avg_rating || 0) > 0 && (
|
<div className="flex items-center gap-2">
|
||||||
<StarRating
|
{isMediaFile(item) && (item.bookmark_count || 0) > 0 && (
|
||||||
rating={item.avg_rating || 0}
|
<span className="text-yellow-500 text-xs flex items-center gap-1">
|
||||||
count={item.star_count || 0}
|
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
size="xs"
|
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z" />
|
||||||
showCount={false}
|
</svg>
|
||||||
/>
|
{item.bookmark_count}
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
|
{isMediaFile(item) && (item.avg_rating || 0) > 0 && (
|
||||||
|
<StarRating
|
||||||
|
rating={item.avg_rating || 0}
|
||||||
|
count={item.star_count || 0}
|
||||||
|
size="xs"
|
||||||
|
showCount={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue