feat(local-player-launcher): add bookmarking and rating functionality

- Add bookmark toggle button with loading state and server API integration
- Implement star rating component with interactive rating updates
- Fetch and display current bookmark and rating status on component mount
- Improve player launch logic to track which player was launched
- Add auto-launch confirmation dialog handling and prevent repeated auto-launch
- Enhance UI to display bookmark, rating controls, and video info with styling
- Remove Elmedia Player from recommended players list
- Refactor player detection and launch functions with useCallback for optimization
- Update UnifiedVideoPlayer to pass bookmark and rating handlers and flags to local player launcher component
This commit is contained in:
tigeren 2025-10-10 16:19:24 +00:00
parent ac06835850
commit fd07b25abf
3 changed files with 268 additions and 144 deletions

Binary file not shown.

View File

@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
@ -11,8 +11,9 @@ import {
PlayCircle,
Monitor,
Settings,
HelpCircle,
X
X,
Bookmark,
Star
} from 'lucide-react';
import { VideoFormat, VideoFile } from '@/lib/video-format-detector';
import { cn } from '@/lib/utils';
@ -22,6 +23,7 @@ import {
shouldAutoLaunch,
PlayerPreferences
} from '@/lib/player-preferences';
import { StarRating } from '@/components/star-rating';
interface LocalPlayerLauncherProps {
video: VideoFile;
@ -30,6 +32,11 @@ interface LocalPlayerLauncherProps {
onPlayerSelect?: (player: string) => void;
formatFileSize?: (bytes: number) => string;
className?: string;
onBookmark?: (id: number) => Promise<void>;
onUnbookmark?: (id: number) => Promise<void>;
onRate?: (id: number, rating: number) => Promise<void>;
showBookmarks?: boolean;
showRatings?: boolean;
}
interface PlayerInfo {
@ -54,15 +61,6 @@ const PLAYER_INFO: Record<string, PlayerInfo> = {
protocolUrl: 'vlc://',
commandLine: 'vlc'
},
elmedia: {
id: 'elmedia',
name: 'Elmedia Player',
icon: '🍎',
description: 'Advanced media player for macOS with streaming capabilities',
platforms: ['macOS'],
downloadUrl: 'https://www.elmedia-video-player.com/',
commandLine: 'open -a "Elmedia Player"'
},
potplayer: {
id: 'potplayer',
name: 'PotPlayer',
@ -146,53 +144,115 @@ export default function LocalPlayerLauncher({
onClose,
onPlayerSelect,
formatFileSize,
className
className,
onBookmark,
onUnbookmark,
onRate,
showBookmarks = true,
showRatings = true
}: LocalPlayerLauncherProps) {
const [copied, setCopied] = useState(false);
const [detectedPlayers, setDetectedPlayers] = useState<string[]>([]);
const [isDetecting, setIsDetecting] = useState(true);
const [launchStatus, setLaunchStatus] = useState<'idle' | 'launching' | 'success' | 'error'>('idle');
const [launchedPlayerId, setLaunchedPlayerId] = useState<string | null>(null); // Track which player was launched
const [preferences, setPreferences] = useState<PlayerPreferences | null>(null);
const [showingAutoLaunchConfirm, setShowingAutoLaunchConfirm] = useState(false);
const [hasAutoLaunched, setHasAutoLaunched] = useState(false); // Prevent repeated auto-launch
// Bookmark and rating states
const [isBookmarked, setIsBookmarked] = useState(false);
const [bookmarkLoading, setBookmarkLoading] = useState(false);
const [currentRating, setCurrentRating] = useState(0);
const [ratingLoading, setRatingLoading] = useState(false);
const streamUrl = getPlayerSpecificUrl(video.id, 'vlc'); // Use optimized endpoint
const recommendedPlayers = format.recommendedPlayers || ['vlc', 'iina', 'elmedia', 'potplayer'];
const recommendedPlayers = format.recommendedPlayers || ['vlc', 'iina', 'potplayer'];
// Load preferences and check for auto-launch on mount
// Check bookmark and rating status on mount
useEffect(() => {
const prefs = loadPlayerPreferences();
setPreferences(prefs);
if (video.id) {
checkBookmarkStatus();
checkRatingStatus();
}
}, [video.id]);
// Check bookmark status
const checkBookmarkStatus = useCallback(async () => {
if (!video.id || !showBookmarks) return;
const autoLaunchCheck = shouldAutoLaunch();
if (autoLaunchCheck.autoLaunch && autoLaunchCheck.playerId) {
if (autoLaunchCheck.needsConfirmation) {
setShowingAutoLaunchConfirm(true);
} else {
// Auto-launch immediately
handlePlayerLaunch(autoLaunchCheck.playerId, true);
try {
const response = await fetch(`/api/bookmarks/${video.id}`);
if (response.ok) {
const data = await response.json();
setIsBookmarked(data.isBookmarked || false);
}
} else {
// Detect available players normally
detectAvailablePlayers();
} catch (error) {
console.error('Error checking bookmark status:', error);
}
}, []);
}, [video.id, showBookmarks]);
// Handle auto-launch confirmation
const handleConfirmAutoLaunch = () => {
const autoLaunchCheck = shouldAutoLaunch();
if (autoLaunchCheck.playerId) {
setShowingAutoLaunchConfirm(false);
handlePlayerLaunch(autoLaunchCheck.playerId, true);
// Check rating status
const checkRatingStatus = useCallback(async () => {
if (!video.id || !showRatings) return;
try {
const response = await fetch(`/api/stars/${video.id}`);
if (response.ok) {
const data = await response.json();
setCurrentRating(data.rating || 0);
}
} catch (error) {
console.error('Error checking rating status:', error);
}
}, [video.id, showRatings]);
// Handle bookmark toggle
const handleBookmarkToggle = useCallback(async () => {
if (!video.id || bookmarkLoading) return;
setBookmarkLoading(true);
try {
if (isBookmarked) {
await onUnbookmark?.(video.id);
setIsBookmarked(false);
} else {
await onBookmark?.(video.id);
setIsBookmarked(true);
}
} catch (error) {
console.error('Error toggling bookmark:', error);
} finally {
setBookmarkLoading(false);
}
}, [video.id, isBookmarked, bookmarkLoading, onBookmark, onUnbookmark]);
// Handle rating change
const handleRatingChange = useCallback(async (rating: number) => {
if (!video.id || ratingLoading) return;
setRatingLoading(true);
try {
await onRate?.(video.id, rating);
setCurrentRating(rating);
} catch (error) {
console.error('Error updating rating:', error);
} finally {
setRatingLoading(false);
}
}, [video.id, ratingLoading, onRate]);
const getPlatform = (): string => {
if (typeof window === 'undefined') return 'Unknown';
const userAgent = window.navigator.userAgent.toLowerCase();
if (userAgent.includes('mac')) return 'macOS';
if (userAgent.includes('win')) return 'Windows';
if (userAgent.includes('linux')) return 'Linux';
return 'Unknown';
};
const handleCancelAutoLaunch = () => {
setShowingAutoLaunchConfirm(false);
detectAvailablePlayers();
};
const detectAvailablePlayers = async () => {
const detectAvailablePlayers = useCallback(async () => {
setIsDetecting(true);
try {
// In a real implementation, this would test protocol handlers
@ -215,17 +275,99 @@ export default function LocalPlayerLauncher({
} finally {
setIsDetecting(false);
}
};
}, [recommendedPlayers]);
const getPlatform = (): string => {
if (typeof window === 'undefined') return 'Unknown';
const handlePlayerLaunch = useCallback(async (playerId: string, isAutoLaunch: boolean = false) => {
const player = PLAYER_INFO[playerId];
if (!player) return;
setLaunchStatus('launching');
setLaunchedPlayerId(playerId);
const userAgent = window.navigator.userAgent.toLowerCase();
if (userAgent.includes('mac')) return 'macOS';
if (userAgent.includes('win')) return 'Windows';
if (userAgent.includes('linux')) return 'Linux';
return 'Unknown';
};
try {
if (player.protocolUrl) {
const protocolUrl = player.protocolUrl + encodeURIComponent(streamUrl);
window.location.href = protocolUrl;
setLaunchStatus('success');
} else {
console.log(`Would launch ${player.name} with: ${streamUrl}`);
setLaunchStatus('success');
}
onPlayerSelect?.(playerId);
if (!isAutoLaunch && preferences && !preferences.preferredPlayer && preferences.rememberChoice) {
const shouldRemember = confirm(`Would you like to remember ${player.name} as your preferred video player? You can change this later in Settings.`);
if (shouldRemember) {
const updatedPrefs = {
...preferences,
preferredPlayer: playerId,
autoLaunch: confirm('Would you like to automatically launch videos with this player in the future?')
};
savePlayerPreferences(updatedPrefs);
setPreferences(updatedPrefs);
}
}
const hasInteractiveFeatures = showBookmarks || showRatings;
if (!hasInteractiveFeatures) {
setTimeout(() => {
onClose();
}, 2000);
}
} catch (error) {
console.error('Failed to launch player:', error);
setLaunchStatus('error');
setLaunchedPlayerId(null);
setTimeout(() => {
setLaunchStatus('idle');
}, 3000);
}
}, [streamUrl, onPlayerSelect, preferences, showBookmarks, showRatings, onClose]);
// Load preferences and check for auto-launch on mount
useEffect(() => {
const prefs = loadPlayerPreferences();
setPreferences(prefs);
const autoLaunchCheck = shouldAutoLaunch();
// Only auto-launch once per dialog session
if (!hasAutoLaunched && autoLaunchCheck.autoLaunch && autoLaunchCheck.playerId) {
if (autoLaunchCheck.needsConfirmation) {
setShowingAutoLaunchConfirm(true);
} else {
// Auto-launch immediately, but still show player detection
setHasAutoLaunched(true); // Mark as launched to prevent repeats
detectAvailablePlayers().then(() => {
handlePlayerLaunch(autoLaunchCheck.playerId!, true);
});
}
} else {
// Detect available players normally
detectAvailablePlayers();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run only once on mount
// Handle auto-launch confirmation
const handleConfirmAutoLaunch = useCallback(() => {
const autoLaunchCheck = shouldAutoLaunch();
if (autoLaunchCheck.playerId) {
setShowingAutoLaunchConfirm(false);
setHasAutoLaunched(true); // Mark as launched
handlePlayerLaunch(autoLaunchCheck.playerId, true);
}
}, [handlePlayerLaunch]);
const handleCancelAutoLaunch = useCallback(() => {
setShowingAutoLaunchConfirm(false);
detectAvailablePlayers();
}, [detectAvailablePlayers]);
const handleCopyUrl = async () => {
try {
@ -246,55 +388,7 @@ export default function LocalPlayerLauncher({
}
};
const handlePlayerLaunch = async (playerId: string, isAutoLaunch: boolean = false) => {
const player = PLAYER_INFO[playerId];
if (!player) return;
setLaunchStatus('launching');
try {
// Try protocol handler first (requires user gesture)
if (player.protocolUrl) {
const protocolUrl = player.protocolUrl + encodeURIComponent(streamUrl);
window.location.href = protocolUrl;
setLaunchStatus('success');
} else {
// Fallback to command line approach (would need server-side support)
console.log(`Would launch ${player.name} with: ${streamUrl}`);
setLaunchStatus('success');
}
onPlayerSelect?.(playerId);
// If this was manual selection and user hasn't set preferences, offer to remember
if (!isAutoLaunch && preferences && !preferences.preferredPlayer && preferences.rememberChoice) {
const shouldRemember = confirm(`Would you like to remember ${player.name} as your preferred video player? You can change this later in Settings.`);
if (shouldRemember) {
const updatedPrefs = {
...preferences,
preferredPlayer: playerId,
autoLaunch: confirm('Would you like to automatically launch videos with this player in the future?')
};
savePlayerPreferences(updatedPrefs);
setPreferences(updatedPrefs);
}
}
// Auto-close after successful launch
setTimeout(() => {
onClose();
}, 2000);
} catch (error) {
console.error('Failed to launch player:', error);
setLaunchStatus('error');
// Reset status after showing error
setTimeout(() => {
setLaunchStatus('idle');
}, 3000);
}
};
const handleManualOpen = () => {
// Open the stream URL in a new tab for manual copy/paste
@ -306,8 +400,8 @@ export default function LocalPlayerLauncher({
if (!player) return null;
const isAvailable = detectedPlayers.includes(playerId);
const isLaunching = launchStatus === 'launching';
const isSuccess = launchStatus === 'success';
const isLaunching = launchStatus === 'launching' && launchedPlayerId === playerId;
const isSuccess = launchStatus === 'success' && launchedPlayerId === playerId;
return (
<Button
@ -425,10 +519,10 @@ export default function LocalPlayerLauncher({
return (
<div className={cn("fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4", className)}>
<Card className="w-full max-w-md">
<Card className="w-full max-w-2xl">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<CardTitle className="flex items-center gap-2">
<Monitor className="h-5 w-5" />
Local Video Player Required
@ -441,7 +535,7 @@ export default function LocalPlayerLauncher({
onClick={onClose}
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-8 w-8 flex-shrink-0 -mt-1"
>
<X className="h-4 w-4" />
</Button>
@ -449,12 +543,64 @@ export default function LocalPlayerLauncher({
</CardHeader>
<CardContent className="space-y-4">
{/* Video Info */}
<div className="bg-muted rounded-lg p-3">
<div className="font-medium text-sm">{video.title || 'Untitled Video'}</div>
<div className="text-xs text-muted-foreground">
Format: {format.streamInfo?.contentType || 'Unknown'}
Size: {formatFileSize ? formatFileSize(video.size) : `${(video.size / 1024 / 1024).toFixed(1)} MB`}
{/* Video Info with Bookmark & Rating */}
<div className="bg-gradient-to-br from-muted/50 to-muted rounded-lg p-4 border border-border">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h3 className="font-semibold text-base line-clamp-1">{video.title || 'Untitled Video'}</h3>
</div>
<div className="text-xs text-muted-foreground space-y-1">
<div className="flex items-center gap-2">
<span>Format: {format.streamInfo?.contentType || 'Unknown'}</span>
<span></span>
<span>Size: {formatFileSize ? formatFileSize(video.size) : `${(video.size / 1024 / 1024).toFixed(1)} MB`}</span>
</div>
<div className="text-xs text-muted-foreground/70 line-clamp-1" title={video.path}>
{video.path}
</div>
</div>
</div>
{/* Bookmark & Rating Controls */}
<div className="flex flex-col gap-2 items-end flex-shrink-0">
{showBookmarks && (
<Button
onClick={handleBookmarkToggle}
disabled={bookmarkLoading}
variant={isBookmarked ? "default" : "outline"}
size="sm"
className={cn(
"h-9 px-3",
isBookmarked && "bg-yellow-500 hover:bg-yellow-600 border-yellow-500"
)}
title={isBookmarked ? "Remove bookmark" : "Add bookmark"}
>
<Bookmark className={cn("h-4 w-4", isBookmarked && "fill-current")} />
<span className="ml-2 text-sm">
{bookmarkLoading ? '...' : isBookmarked ? 'Bookmarked' : 'Bookmark'}
</span>
</Button>
)}
{showRatings && (
<div className="flex items-center gap-1.5">
<StarRating
rating={currentRating}
count={0}
size="sm"
showCount={false}
interactive={true}
onRate={handleRatingChange}
/>
{currentRating > 0 && (
<span className="text-xs text-muted-foreground ml-1">
{currentRating}/5
</span>
)}
</div>
)}
</div>
</div>
</div>
@ -464,6 +610,13 @@ export default function LocalPlayerLauncher({
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
Detecting available players...
</div>
) : launchStatus === 'success' ? (
<Alert className="bg-green-500/10 border-green-500/20">
<Check className="h-4 w-4 text-green-400" />
<AlertDescription className="text-green-400">
Player launched successfully! You can now bookmark or rate this video before closing.
</AlertDescription>
</Alert>
) : (
<Alert>
<Monitor className="h-4 w-4" />
@ -535,40 +688,6 @@ export default function LocalPlayerLauncher({
Settings
</Button>
</div>
{/* Help Section */}
<div className="bg-muted rounded-lg p-3 text-xs">
<div className="flex items-center gap-2 font-medium mb-1">
<HelpCircle className="h-3 w-3" />
Better Streaming Solution
</div>
<div className="space-y-2 text-muted-foreground">
<div className="bg-blue-500/10 border border-blue-500/20 rounded p-2">
<div className="font-medium text-blue-400 mb-1">💡 Recommended Solution</div>
<div className="text-xs">
Use our <strong>External Streaming API</strong> for better compatibility:
<br />
<code className="bg-black/20 px-1 rounded text-xs mt-1 inline-block">
{getPlayerSpecificUrl(video.id, 'vlc')}
</code>
</div>
</div>
<ul className="space-y-1">
<li> Proper HTTP range support for seeking</li>
<li> Optimized chunked streaming</li>
<li> Works with VLC, MPV, PotPlayer, etc.</li>
<li> No transcoding needed</li>
</ul>
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded">
<div className="font-medium text-green-400">Quick Setup:</div>
<div className="text-xs mt-1">
1. Copy URL above<br />
2. Open VLC Media Open Network Stream<br />
3. Paste URL and click Play
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@ -175,6 +175,11 @@ export default function UnifiedVideoPlayer({
console.log(`Selected player: ${playerId}`);
}}
formatFileSize={formatFileSize}
onBookmark={handleBookmarkToggle}
onUnbookmark={handleUnbookmark}
onRate={handleRatingUpdate}
showBookmarks={showBookmarks}
showRatings={showRatings}
/>
);
}