276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
import { motion } from 'framer-motion';
|
|
import { Download, Trash2, Pause, Play, FolderOpen, X } from 'lucide-react';
|
|
import { Link } from 'react-router-dom';
|
|
import Button from '../components/ui/Button';
|
|
import ProgressBar from '../components/ui/ProgressBar';
|
|
import Badge from '../components/ui/Badge';
|
|
import { useDownloadStore } from '../stores/downloadStore';
|
|
import { formatBytes, formatSpeed } from '../services/torrent/webtorrent';
|
|
|
|
export default function Downloads() {
|
|
const {
|
|
items,
|
|
pauseDownload,
|
|
resumeDownload,
|
|
removeDownload,
|
|
clearCompleted,
|
|
} = useDownloadStore();
|
|
|
|
const activeDownloads = items.filter(
|
|
(item) => item.status === 'downloading' || item.status === 'queued'
|
|
);
|
|
const completedDownloads = items.filter((item) => item.status === 'completed');
|
|
const failedDownloads = items.filter((item) => item.status === 'error');
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
switch (status) {
|
|
case 'downloading':
|
|
return <Badge variant="info">Downloading</Badge>;
|
|
case 'queued':
|
|
return <Badge>Queued</Badge>;
|
|
case 'paused':
|
|
return <Badge variant="warning">Paused</Badge>;
|
|
case 'completed':
|
|
return <Badge variant="success">Completed</Badge>;
|
|
case 'error':
|
|
return <Badge variant="error">Failed</Badge>;
|
|
default:
|
|
return <Badge>{status}</Badge>;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className="min-h-screen bg-netflix-black pt-24 pb-16"
|
|
>
|
|
<div className="max-w-[1920px] mx-auto px-4 md:px-8">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 className="text-3xl md:text-4xl font-bold flex items-center gap-3">
|
|
<Download className="text-netflix-red" />
|
|
Downloads
|
|
</h1>
|
|
<p className="text-gray-400 mt-1">
|
|
{activeDownloads.length} active, {completedDownloads.length} completed
|
|
</p>
|
|
</div>
|
|
|
|
{completedDownloads.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={clearCompleted}
|
|
leftIcon={<Trash2 size={18} />}
|
|
>
|
|
Clear Completed
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Active Downloads */}
|
|
{activeDownloads.length > 0 && (
|
|
<div className="mb-8">
|
|
<h2 className="text-xl font-semibold mb-4">Active Downloads</h2>
|
|
<div className="space-y-4">
|
|
{activeDownloads.map((item, index) => (
|
|
<motion.div
|
|
key={item.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: index * 0.05 }}
|
|
className="p-4 glass rounded-lg"
|
|
>
|
|
<div className="flex gap-4">
|
|
{/* Poster */}
|
|
<Link to={`/movie/${item.movie.id}`} className="flex-shrink-0">
|
|
<img
|
|
src={item.movie.medium_cover_image}
|
|
alt={item.movie.title}
|
|
className="w-20 md:w-24 rounded-md"
|
|
/>
|
|
</Link>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h3 className="font-semibold line-clamp-1">
|
|
{item.movie.title}
|
|
</h3>
|
|
<div className="flex items-center gap-2 text-sm text-gray-400 mt-1">
|
|
<span>{item.torrent.quality}</span>
|
|
<span>•</span>
|
|
<span>{item.torrent.size}</span>
|
|
</div>
|
|
</div>
|
|
{getStatusBadge(item.status)}
|
|
</div>
|
|
|
|
{/* Progress */}
|
|
<div className="mt-4">
|
|
<div className="flex justify-between text-sm mb-1">
|
|
<span>{(item.progress * 100).toFixed(1)}%</span>
|
|
<span className="text-gray-400">
|
|
{formatSpeed(item.downloadSpeed)} • {item.peers} peers
|
|
</span>
|
|
</div>
|
|
<ProgressBar progress={item.progress * 100} />
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2 mt-4">
|
|
{item.status === 'downloading' ? (
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => pauseDownload(item.id)}
|
|
leftIcon={<Pause size={16} />}
|
|
>
|
|
Pause
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => resumeDownload(item.id)}
|
|
leftIcon={<Play size={16} />}
|
|
>
|
|
Resume
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
if (confirm('Cancel this download?')) {
|
|
removeDownload(item.id);
|
|
}
|
|
}}
|
|
leftIcon={<X size={16} />}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Completed Downloads */}
|
|
{completedDownloads.length > 0 && (
|
|
<div className="mb-8">
|
|
<h2 className="text-xl font-semibold mb-4">Completed</h2>
|
|
<div className="space-y-4">
|
|
{completedDownloads.map((item, index) => (
|
|
<motion.div
|
|
key={item.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: index * 0.05 }}
|
|
className="flex items-center gap-4 p-4 glass rounded-lg"
|
|
>
|
|
<Link to={`/movie/${item.movie.id}`} className="flex-shrink-0">
|
|
<img
|
|
src={item.movie.medium_cover_image}
|
|
alt={item.movie.title}
|
|
className="w-16 rounded-md"
|
|
/>
|
|
</Link>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-semibold line-clamp-1">{item.movie.title}</h3>
|
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
|
<span>{item.torrent.quality}</span>
|
|
<span>•</span>
|
|
<span>{item.torrent.size}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Link to={`/player/${item.movie.id}`}>
|
|
<Button size="sm" leftIcon={<Play size={16} />}>
|
|
Play
|
|
</Button>
|
|
</Link>
|
|
{item.filePath && (
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
leftIcon={<FolderOpen size={16} />}
|
|
>
|
|
Open
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeDownload(item.id)}
|
|
>
|
|
<Trash2 size={16} />
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Failed Downloads */}
|
|
{failedDownloads.length > 0 && (
|
|
<div className="mb-8">
|
|
<h2 className="text-xl font-semibold mb-4">Failed</h2>
|
|
<div className="space-y-4">
|
|
{failedDownloads.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="flex items-center gap-4 p-4 glass rounded-lg border border-red-500/20"
|
|
>
|
|
<img
|
|
src={item.movie.medium_cover_image}
|
|
alt={item.movie.title}
|
|
className="w-16 rounded-md opacity-50"
|
|
/>
|
|
<div className="flex-1">
|
|
<h3 className="font-semibold">{item.movie.title}</h3>
|
|
<p className="text-sm text-red-400">{item.error}</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeDownload(item.id)}
|
|
>
|
|
<Trash2 size={16} />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{items.length === 0 && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="text-center py-16"
|
|
>
|
|
<Download size={64} className="mx-auto text-gray-600 mb-4" />
|
|
<h2 className="text-xl text-gray-400 mb-2">No downloads</h2>
|
|
<p className="text-gray-500 mb-6">
|
|
Download movies for offline viewing
|
|
</p>
|
|
<Link to="/browse">
|
|
<Button>Browse Movies</Button>
|
|
</Link>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|