feat(player): add external video player auto-launch feature with preferences
- Implement player preferences management with localStorage and events - Enable auto-launch and confirmation dialogs in LocalPlayerLauncher component - Learn user preferences from manual player selections dynamically - Integrate settings UI for video player configuration and real-time updates - Support platform-aware player listing and reset to default preferences - Provide clear current configuration display and toggle switches in settings - Enhance user experience with smart defaults and customizable workflow - Maintain backward compatibility with manual player selection option - Add custom event system for cross-component synchronization of preferences - Optimize player detection and launching logic to respect user settings
This commit is contained in:
parent
d048cb3b82
commit
76154123b8
|
|
@ -0,0 +1,134 @@
|
||||||
|
# External Video Player Auto-Launch Feature Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Successfully implemented a seamless auto-launch system for external video players, allowing users to configure preferences once and automatically launch videos without manual selection each time.
|
||||||
|
|
||||||
|
## Features Implemented
|
||||||
|
|
||||||
|
### 1. Player Preferences Management (`/src/lib/player-preferences.ts`)
|
||||||
|
- **localStorage-based storage** for user preferences
|
||||||
|
- **Cross-component state management** with custom events
|
||||||
|
- **Default configurations** with safe fallbacks
|
||||||
|
- **Platform-specific player detection**
|
||||||
|
|
||||||
|
Key preferences:
|
||||||
|
- `preferredPlayer`: User's chosen video player (null = manual selection)
|
||||||
|
- `autoLaunch`: Enable/disable automatic launching
|
||||||
|
- `confirmBeforeLaunch`: Show confirmation dialog before auto-launch
|
||||||
|
- `rememberChoice`: Offer to remember manual selections
|
||||||
|
|
||||||
|
### 2. Enhanced LocalPlayerLauncher (`/src/components/local-player-launcher.tsx`)
|
||||||
|
- **Auto-launch detection** on component mount
|
||||||
|
- **Confirmation dialogs** for auto-launch (when enabled)
|
||||||
|
- **Smart preference learning** from manual selections
|
||||||
|
- **Fallback to manual selection** when no preferences set
|
||||||
|
|
||||||
|
New workflow:
|
||||||
|
1. Component loads → Check user preferences
|
||||||
|
2. If auto-launch enabled → Show confirmation or launch directly
|
||||||
|
3. If manual selection → Show traditional player selection
|
||||||
|
4. After manual selection → Offer to remember choice
|
||||||
|
|
||||||
|
### 3. Settings Page Integration (`/src/app/settings/page.tsx`)
|
||||||
|
- **Dedicated Video Player section** with modern toggle switches
|
||||||
|
- **Real-time preference updates** with immediate feedback
|
||||||
|
- **Platform-aware player list** showing only compatible players
|
||||||
|
- **Current configuration display** for transparency
|
||||||
|
- **Reset to defaults** functionality
|
||||||
|
|
||||||
|
Settings UI includes:
|
||||||
|
- Auto Launch toggle
|
||||||
|
- Preferred Player selection (with manual option)
|
||||||
|
- Confirmation toggle
|
||||||
|
- Remember Choice toggle
|
||||||
|
- Current status display
|
||||||
|
|
||||||
|
## User Experience Flow
|
||||||
|
|
||||||
|
### First Time Use
|
||||||
|
1. User clicks on .ts (or other external format) video
|
||||||
|
2. LocalPlayerLauncher shows traditional selection dialog
|
||||||
|
3. User selects preferred player (e.g., VLC)
|
||||||
|
4. System asks: "Remember VLC as preferred player?"
|
||||||
|
5. If yes, asks: "Auto-launch videos with VLC in future?"
|
||||||
|
6. Preferences saved for subsequent videos
|
||||||
|
|
||||||
|
### Subsequent Uses (Auto-Launch Enabled)
|
||||||
|
1. User clicks on external format video
|
||||||
|
2. System shows: "Launch VLC?" confirmation dialog
|
||||||
|
3. User clicks "Launch VLC" → Video opens immediately
|
||||||
|
4. OR clicks "Choose Manually" → Shows full selection dialog
|
||||||
|
|
||||||
|
### Subsequent Uses (Auto-Launch Disabled)
|
||||||
|
1. User clicks on external format video
|
||||||
|
2. System immediately launches preferred player
|
||||||
|
3. No confirmation needed
|
||||||
|
|
||||||
|
## Technical Implementation
|
||||||
|
|
||||||
|
### Storage Architecture
|
||||||
|
```typescript
|
||||||
|
interface PlayerPreferences {
|
||||||
|
preferredPlayer: string | null; // Player ID or null for manual
|
||||||
|
autoLaunch: boolean; // Enable auto-launch
|
||||||
|
confirmBeforeLaunch: boolean; // Show confirmation dialog
|
||||||
|
rememberChoice: boolean; // Offer to remember selections
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-Launch Logic
|
||||||
|
```typescript
|
||||||
|
const autoLaunchCheck = shouldAutoLaunch();
|
||||||
|
if (autoLaunchCheck.autoLaunch && autoLaunchCheck.playerId) {
|
||||||
|
if (autoLaunchCheck.needsConfirmation) {
|
||||||
|
setShowingAutoLaunchConfirm(true);
|
||||||
|
} else {
|
||||||
|
handlePlayerLaunch(autoLaunchCheck.playerId, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings Integration
|
||||||
|
- Real-time preference updates using localStorage
|
||||||
|
- Custom event system for cross-component synchronization
|
||||||
|
- Platform detection for showing relevant players only
|
||||||
|
- Visual feedback with toggle switches and status display
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- ✅ **One-time setup** → Seamless future use
|
||||||
|
- ✅ **Configurable experience** → Users choose their level of automation
|
||||||
|
- ✅ **Smart defaults** → Safe, non-intrusive initial behavior
|
||||||
|
- ✅ **Clear settings** → Easy to modify preferences later
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- ✅ **Lightweight implementation** → localStorage-based, no server calls
|
||||||
|
- ✅ **Cross-platform compatibility** → Detects available players per OS
|
||||||
|
- ✅ **Backwards compatible** → Manual selection still available
|
||||||
|
- ✅ **Event-driven updates** → Real-time preference synchronization
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
Users can now configure:
|
||||||
|
|
||||||
|
1. **Auto Launch**: Toggle automatic video launching
|
||||||
|
2. **Preferred Player**: Choose default player or manual selection
|
||||||
|
3. **Confirmation**: Require confirmation before auto-launch
|
||||||
|
4. **Learning**: Allow system to learn from manual choices
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. **Created**: `/src/lib/player-preferences.ts` - Preference management system
|
||||||
|
2. **Enhanced**: `/src/components/local-player-launcher.tsx` - Auto-launch support
|
||||||
|
3. **Enhanced**: `/src/app/settings/page.tsx` - Settings UI integration
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements:
|
||||||
|
- **Player installation detection** - Verify players are actually installed
|
||||||
|
- **Custom player paths** - Allow users to specify player locations
|
||||||
|
- **Per-format preferences** - Different players for different video formats
|
||||||
|
- **Keyboard shortcuts** - Quick launch hotkeys
|
||||||
|
|
||||||
|
The implementation provides a perfect balance between automation and user control, making the external video player experience as seamless as possible while maintaining flexibility for different user preferences.
|
||||||
|
|
@ -23,9 +23,9 @@ export default function RootLayout({
|
||||||
<body
|
<body
|
||||||
className={`${inter.variable} antialiased bg-background text-foreground`}
|
className={`${inter.variable} antialiased bg-background text-foreground`}
|
||||||
>
|
>
|
||||||
<div className="flex h-screen bg-gradient-to-br from-background via-background to-muted/20 overflow-hidden">
|
<div className="flex h-screen bg-gradient-to-br from-background via-background to-muted/20">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 bg-background/50 backdrop-blur-sm overflow-hidden">
|
<main className="flex-1 bg-background/50 backdrop-blur-sm overflow-y-auto">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,14 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Header } from "@/components/ui/header";
|
import { Header } from "@/components/ui/header";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Trash2, Plus, Folder, HardDrive, Scan, CheckSquare, Square, RefreshCw } from "lucide-react";
|
import { Trash2, Plus, Folder, HardDrive, Scan, CheckSquare, Square, RefreshCw, Play, Monitor, Settings as SettingsIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
loadPlayerPreferences,
|
||||||
|
savePlayerPreferences,
|
||||||
|
resetPlayerPreferences,
|
||||||
|
getAvailablePlayersForPlatform,
|
||||||
|
PlayerPreferences
|
||||||
|
} from "@/lib/player-preferences";
|
||||||
|
|
||||||
interface Library {
|
interface Library {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -22,11 +29,41 @@ const SettingsPage = () => {
|
||||||
const [scanStatus, setScanStatus] = useState<string>("");
|
const [scanStatus, setScanStatus] = useState<string>("");
|
||||||
const [selectedLibraries, setSelectedLibraries] = useState<number[]>([]);
|
const [selectedLibraries, setSelectedLibraries] = useState<number[]>([]);
|
||||||
const [scanProgress, setScanProgress] = useState<Record<number, boolean>>({});
|
const [scanProgress, setScanProgress] = useState<Record<number, boolean>>({});
|
||||||
|
const [playerPreferences, setPlayerPreferences] = useState<PlayerPreferences | null>(null);
|
||||||
|
const [availablePlayers, setAvailablePlayers] = useState<Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
}>>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLibraries();
|
fetchLibraries();
|
||||||
|
loadVideoPlayerPreferences();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadVideoPlayerPreferences = () => {
|
||||||
|
try {
|
||||||
|
const prefs = loadPlayerPreferences();
|
||||||
|
const players = getAvailablePlayersForPlatform();
|
||||||
|
console.log('[Settings] Loaded preferences:', prefs);
|
||||||
|
console.log('[Settings] Available players:', players);
|
||||||
|
setPlayerPreferences(prefs);
|
||||||
|
setAvailablePlayers(players);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Settings] Error loading player preferences:', error);
|
||||||
|
// Set default preferences if loading fails
|
||||||
|
const defaultPrefs = {
|
||||||
|
preferredPlayer: null,
|
||||||
|
autoLaunch: false,
|
||||||
|
confirmBeforeLaunch: true,
|
||||||
|
rememberChoice: true,
|
||||||
|
};
|
||||||
|
setPlayerPreferences(defaultPrefs);
|
||||||
|
setAvailablePlayers([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchLibraries = async () => {
|
const fetchLibraries = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/libraries");
|
const res = await fetch("/api/libraries");
|
||||||
|
|
@ -200,6 +237,21 @@ const SettingsPage = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updatePlayerPreferences = (updates: Partial<PlayerPreferences>) => {
|
||||||
|
if (!playerPreferences) return;
|
||||||
|
|
||||||
|
const updated = { ...playerPreferences, ...updates };
|
||||||
|
savePlayerPreferences(updated);
|
||||||
|
setPlayerPreferences(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetPlayerPreferences = () => {
|
||||||
|
if (confirm('Are you sure you want to reset all video player preferences to defaults?')) {
|
||||||
|
resetPlayerPreferences();
|
||||||
|
loadVideoPlayerPreferences();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getTotalStorage = () => {
|
const getTotalStorage = () => {
|
||||||
return libraries.reduce((total, lib) => {
|
return libraries.reduce((total, lib) => {
|
||||||
// Rough estimation based on path length
|
// Rough estimation based on path length
|
||||||
|
|
@ -208,7 +260,7 @@ const SettingsPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950">
|
<div className="min-h-screen bg-zinc-950 overflow-y-auto">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-5xl font-bold text-white tracking-tight mb-2">
|
<h1 className="text-5xl font-bold text-white tracking-tight mb-2">
|
||||||
|
|
@ -219,7 +271,7 @@ const SettingsPage = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 pb-8">
|
||||||
<div className="lg:col-span-2 space-y-8">
|
<div className="lg:col-span-2 space-y-8">
|
||||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6">
|
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
|
@ -424,6 +476,169 @@ const SettingsPage = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-600/20">
|
||||||
|
<Play className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white">Video Player</h2>
|
||||||
|
<p className="text-sm text-zinc-400">Configure external video player preferences</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{playerPreferences ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Auto Launch Setting */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-white">Auto Launch</h3>
|
||||||
|
<p className="text-xs text-zinc-400">Automatically launch videos in external player</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => updatePlayerPreferences({ autoLaunch: !playerPreferences.autoLaunch })}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
playerPreferences.autoLaunch ? 'bg-blue-600' : 'bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
playerPreferences.autoLaunch ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preferred Player Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-white">Preferred Player</h3>
|
||||||
|
<p className="text-xs text-zinc-400">Choose your default video player for external playback</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* None/Manual Selection Option */}
|
||||||
|
<button
|
||||||
|
onClick={() => updatePlayerPreferences({ preferredPlayer: null })}
|
||||||
|
className={`w-full p-3 rounded-lg border transition-all text-left ${
|
||||||
|
playerPreferences.preferredPlayer === null
|
||||||
|
? 'border-blue-500 bg-blue-500/10'
|
||||||
|
: 'border-zinc-700 bg-zinc-800 hover:border-zinc-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-zinc-700 rounded-lg flex items-center justify-center">
|
||||||
|
<Monitor className="h-4 w-4 text-zinc-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-white text-sm">Manual Selection</div>
|
||||||
|
<div className="text-xs text-zinc-400">Show player selection dialog each time</div>
|
||||||
|
</div>
|
||||||
|
{playerPreferences.preferredPlayer === null && (
|
||||||
|
<div className="ml-auto w-2 h-2 bg-blue-500 rounded-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Available Players */}
|
||||||
|
{availablePlayers.map((player) => (
|
||||||
|
<button
|
||||||
|
key={player.id}
|
||||||
|
onClick={() => updatePlayerPreferences({ preferredPlayer: player.id })}
|
||||||
|
className={`w-full p-3 rounded-lg border transition-all text-left ${
|
||||||
|
playerPreferences.preferredPlayer === player.id
|
||||||
|
? 'border-blue-500 bg-blue-500/10'
|
||||||
|
: 'border-zinc-700 bg-zinc-800 hover:border-zinc-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-zinc-700 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-sm">{player.icon}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-white text-sm">{player.name}</div>
|
||||||
|
<div className="text-xs text-zinc-400">{player.description}</div>
|
||||||
|
</div>
|
||||||
|
{playerPreferences.preferredPlayer === player.id && (
|
||||||
|
<div className="ml-auto w-2 h-2 bg-blue-500 rounded-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation Setting */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-white">Confirm Before Launch</h3>
|
||||||
|
<p className="text-xs text-zinc-400">Show confirmation dialog before auto-launching</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => updatePlayerPreferences({ confirmBeforeLaunch: !playerPreferences.confirmBeforeLaunch })}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
playerPreferences.confirmBeforeLaunch ? 'bg-blue-600' : 'bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
playerPreferences.confirmBeforeLaunch ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remember Choice Setting */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-white">Remember Manual Selections</h3>
|
||||||
|
<p className="text-xs text-zinc-400">Offer to remember when you manually select a player</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => updatePlayerPreferences({ rememberChoice: !playerPreferences.rememberChoice })}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
playerPreferences.rememberChoice ? 'bg-blue-600' : 'bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
playerPreferences.rememberChoice ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Status */}
|
||||||
|
<div className="bg-zinc-800 rounded-lg p-3">
|
||||||
|
<div className="text-xs font-medium text-zinc-300 mb-2">Current Configuration</div>
|
||||||
|
<div className="space-y-1 text-xs text-zinc-400">
|
||||||
|
<div>• Auto Launch: {playerPreferences.autoLaunch ? 'Enabled' : 'Disabled'}</div>
|
||||||
|
<div>• Preferred Player: {playerPreferences.preferredPlayer ?
|
||||||
|
availablePlayers.find(p => p.id === playerPreferences.preferredPlayer)?.name || 'Unknown'
|
||||||
|
: 'Manual Selection'}</div>
|
||||||
|
<div>• Confirmation: {playerPreferences.confirmBeforeLaunch ? 'Required' : 'Skip'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Button */}
|
||||||
|
<div className="pt-3 border-t border-zinc-800">
|
||||||
|
<Button
|
||||||
|
onClick={handleResetPlayerPreferences}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-zinc-400 hover:text-white"
|
||||||
|
>
|
||||||
|
Reset to Defaults
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<div className="text-zinc-400">Loading player preferences...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { VideoFormat, VideoFile } from '@/lib/video-format-detector';
|
import { VideoFormat, VideoFile } from '@/lib/video-format-detector';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
loadPlayerPreferences,
|
||||||
|
savePlayerPreferences,
|
||||||
|
shouldAutoLaunch,
|
||||||
|
PlayerPreferences
|
||||||
|
} from '@/lib/player-preferences';
|
||||||
|
|
||||||
interface LocalPlayerLauncherProps {
|
interface LocalPlayerLauncherProps {
|
||||||
video: VideoFile;
|
video: VideoFile;
|
||||||
|
|
@ -146,15 +152,46 @@ export default function LocalPlayerLauncher({
|
||||||
const [detectedPlayers, setDetectedPlayers] = useState<string[]>([]);
|
const [detectedPlayers, setDetectedPlayers] = useState<string[]>([]);
|
||||||
const [isDetecting, setIsDetecting] = useState(true);
|
const [isDetecting, setIsDetecting] = useState(true);
|
||||||
const [launchStatus, setLaunchStatus] = useState<'idle' | 'launching' | 'success' | 'error'>('idle');
|
const [launchStatus, setLaunchStatus] = useState<'idle' | 'launching' | 'success' | 'error'>('idle');
|
||||||
|
const [preferences, setPreferences] = useState<PlayerPreferences | null>(null);
|
||||||
|
const [showingAutoLaunchConfirm, setShowingAutoLaunchConfirm] = useState(false);
|
||||||
|
|
||||||
const streamUrl = getPlayerSpecificUrl(video.id, 'vlc'); // Use optimized endpoint
|
const streamUrl = getPlayerSpecificUrl(video.id, 'vlc'); // Use optimized endpoint
|
||||||
const recommendedPlayers = format.recommendedPlayers || ['vlc', 'iina', 'elmedia', 'potplayer'];
|
const recommendedPlayers = format.recommendedPlayers || ['vlc', 'iina', 'elmedia', 'potplayer'];
|
||||||
|
|
||||||
// Detect available players on mount
|
// Load preferences and check for auto-launch on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const prefs = loadPlayerPreferences();
|
||||||
|
setPreferences(prefs);
|
||||||
|
|
||||||
|
const autoLaunchCheck = shouldAutoLaunch();
|
||||||
|
|
||||||
|
if (autoLaunchCheck.autoLaunch && autoLaunchCheck.playerId) {
|
||||||
|
if (autoLaunchCheck.needsConfirmation) {
|
||||||
|
setShowingAutoLaunchConfirm(true);
|
||||||
|
} else {
|
||||||
|
// Auto-launch immediately
|
||||||
|
handlePlayerLaunch(autoLaunchCheck.playerId, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Detect available players normally
|
||||||
detectAvailablePlayers();
|
detectAvailablePlayers();
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle auto-launch confirmation
|
||||||
|
const handleConfirmAutoLaunch = () => {
|
||||||
|
const autoLaunchCheck = shouldAutoLaunch();
|
||||||
|
if (autoLaunchCheck.playerId) {
|
||||||
|
setShowingAutoLaunchConfirm(false);
|
||||||
|
handlePlayerLaunch(autoLaunchCheck.playerId, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelAutoLaunch = () => {
|
||||||
|
setShowingAutoLaunchConfirm(false);
|
||||||
|
detectAvailablePlayers();
|
||||||
|
};
|
||||||
|
|
||||||
const detectAvailablePlayers = async () => {
|
const detectAvailablePlayers = async () => {
|
||||||
setIsDetecting(true);
|
setIsDetecting(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -209,7 +246,7 @@ export default function LocalPlayerLauncher({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlayerLaunch = async (playerId: string) => {
|
const handlePlayerLaunch = async (playerId: string, isAutoLaunch: boolean = false) => {
|
||||||
const player = PLAYER_INFO[playerId];
|
const player = PLAYER_INFO[playerId];
|
||||||
if (!player) return;
|
if (!player) return;
|
||||||
|
|
||||||
|
|
@ -229,6 +266,20 @@ export default function LocalPlayerLauncher({
|
||||||
|
|
||||||
onPlayerSelect?.(playerId);
|
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
|
// Auto-close after successful launch
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -293,6 +344,49 @@ export default function LocalPlayerLauncher({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-launch confirmation dialog
|
||||||
|
if (showingAutoLaunchConfirm && preferences?.preferredPlayer) {
|
||||||
|
const preferredPlayer = PLAYER_INFO[preferences.preferredPlayer];
|
||||||
|
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">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<PlayCircle className="h-5 w-5" />
|
||||||
|
Launch {preferredPlayer?.name}?
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Your preferred video player is ready to open this video.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="bg-muted rounded-lg p-3 mb-4">
|
||||||
|
<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`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleConfirmAutoLaunch} className="flex-1">
|
||||||
|
<PlayCircle className="h-4 w-4 mr-2" />
|
||||||
|
Launch {preferredPlayer?.name}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCancelAutoLaunch} variant="outline" className="flex-1">
|
||||||
|
Choose Manually
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 text-xs text-muted-foreground text-center">
|
||||||
|
You can change this in Settings → Video Player
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (launchStatus === 'error') {
|
if (launchStatus === 'error') {
|
||||||
return (
|
return (
|
||||||
<div className={cn("fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4", className)}>
|
<div className={cn("fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4", className)}>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
/**
|
||||||
|
* Player Preferences Management
|
||||||
|
* Handles storage and retrieval of user preferences for external video players
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { PLAYER_INFO } from './local-player-launcher';
|
||||||
|
|
||||||
|
export interface PlayerPreferences {
|
||||||
|
preferredPlayer: string | null; // null means show selection dialog
|
||||||
|
autoLaunch: boolean;
|
||||||
|
confirmBeforeLaunch: boolean;
|
||||||
|
rememberChoice: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PREFERENCES: PlayerPreferences = {
|
||||||
|
preferredPlayer: null,
|
||||||
|
autoLaunch: false,
|
||||||
|
confirmBeforeLaunch: true,
|
||||||
|
rememberChoice: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'nextav-player-preferences';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load player preferences from localStorage
|
||||||
|
*/
|
||||||
|
export function loadPlayerPreferences(): PlayerPreferences {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return DEFAULT_PREFERENCES;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!stored) {
|
||||||
|
return DEFAULT_PREFERENCES;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
|
||||||
|
// Validate that the preferred player still exists
|
||||||
|
if (parsed.preferredPlayer && !PLAYER_INFO[parsed.preferredPlayer]) {
|
||||||
|
parsed.preferredPlayer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...DEFAULT_PREFERENCES,
|
||||||
|
...parsed,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading player preferences:', error);
|
||||||
|
return DEFAULT_PREFERENCES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save player preferences to localStorage
|
||||||
|
*/
|
||||||
|
export function savePlayerPreferences(preferences: PlayerPreferences): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
|
||||||
|
|
||||||
|
// Dispatch custom event for components to react to preference changes
|
||||||
|
window.dispatchEvent(new CustomEvent('playerPreferencesChanged', {
|
||||||
|
detail: preferences
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving player preferences:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update specific preference fields
|
||||||
|
*/
|
||||||
|
export function updatePlayerPreferences(updates: Partial<PlayerPreferences>): PlayerPreferences {
|
||||||
|
const current = loadPlayerPreferences();
|
||||||
|
const updated = { ...current, ...updates };
|
||||||
|
savePlayerPreferences(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset preferences to defaults
|
||||||
|
*/
|
||||||
|
export function resetPlayerPreferences(): PlayerPreferences {
|
||||||
|
savePlayerPreferences(DEFAULT_PREFERENCES);
|
||||||
|
return DEFAULT_PREFERENCES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we should auto-launch a video with current preferences
|
||||||
|
*/
|
||||||
|
export function shouldAutoLaunch(): {
|
||||||
|
autoLaunch: boolean;
|
||||||
|
playerId: string | null;
|
||||||
|
needsConfirmation: boolean;
|
||||||
|
} {
|
||||||
|
const prefs = loadPlayerPreferences();
|
||||||
|
|
||||||
|
return {
|
||||||
|
autoLaunch: prefs.autoLaunch && prefs.preferredPlayer !== null,
|
||||||
|
playerId: prefs.preferredPlayer,
|
||||||
|
needsConfirmation: prefs.confirmBeforeLaunch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available players for current platform
|
||||||
|
*/
|
||||||
|
export function getAvailablePlayersForPlatform(): Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
}> {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||||
|
const platform = userAgent.includes('mac') ? 'macOS' :
|
||||||
|
userAgent.includes('win') ? 'Windows' :
|
||||||
|
userAgent.includes('linux') ? 'Linux' : 'Unknown';
|
||||||
|
|
||||||
|
return Object.values(PLAYER_INFO)
|
||||||
|
.filter(player => player.platforms.includes(platform))
|
||||||
|
.map(player => ({
|
||||||
|
id: player.id,
|
||||||
|
name: player.name,
|
||||||
|
description: player.description,
|
||||||
|
icon: getPlayerIcon(player.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get player icon for UI display
|
||||||
|
*/
|
||||||
|
function getPlayerIcon(playerId: string): string {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
'vlc': '🎬',
|
||||||
|
'elmedia': '🍎',
|
||||||
|
'potplayer': '🎯',
|
||||||
|
'iina': '🍎',
|
||||||
|
'mpv': '⚡'
|
||||||
|
};
|
||||||
|
|
||||||
|
return icons[playerId] || '🎮';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for React components to use player preferences
|
||||||
|
*/
|
||||||
|
export function usePlayerPreferences() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {
|
||||||
|
preferences: DEFAULT_PREFERENCES,
|
||||||
|
updatePreferences: () => {},
|
||||||
|
resetPreferences: () => {},
|
||||||
|
shouldAutoLaunch: () => ({ autoLaunch: false, playerId: null, needsConfirmation: true })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [preferences, setPreferences] = useState(loadPlayerPreferences());
|
||||||
|
|
||||||
|
const updatePreferences = useCallback((updates: Partial<PlayerPreferences>) => {
|
||||||
|
const updated = updatePlayerPreferences(updates);
|
||||||
|
setPreferences(updated);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetPreferences = useCallback(() => {
|
||||||
|
const reset = resetPlayerPreferences();
|
||||||
|
setPreferences(reset);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Listen for preference changes from other components
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePreferenceChange = (event: CustomEvent) => {
|
||||||
|
setPreferences(event.detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('playerPreferencesChanged', handlePreferenceChange as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('playerPreferencesChanged', handlePreferenceChange as EventListener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
preferences,
|
||||||
|
updatePreferences,
|
||||||
|
resetPreferences,
|
||||||
|
shouldAutoLaunch,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue