168 lines
6.8 KiB
TypeScript
168 lines
6.8 KiB
TypeScript
import { useMemo } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { motion } from 'framer-motion'
|
|
import { Download, Trash2, Play, AlertCircle, Check, Clock } from '../lib/icons'
|
|
import { useDownloads } from '../stores/downloads-store'
|
|
import { isTauri } from '../lib/tauri'
|
|
|
|
export default function DownloadsPage() {
|
|
const navigate = useNavigate()
|
|
const items = useDownloads(s => s.items)
|
|
const remove = useDownloads(s => s.remove)
|
|
const clearCompleted = useDownloads(s => s.clearCompleted)
|
|
|
|
const grouped = useMemo(() => {
|
|
const active = items.filter(i => i.status === 'queued' || i.status === 'downloading')
|
|
const done = items.filter(i => i.status === 'done')
|
|
const error = items.filter(i => i.status === 'error')
|
|
return { active, done, error }
|
|
}, [items])
|
|
|
|
return (
|
|
<div className="px-7 pt-4 pb-12">
|
|
<header className="mb-7 flex items-end justify-between gap-4">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
|
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Offline</span>
|
|
</div>
|
|
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
|
|
Downloads
|
|
</h1>
|
|
<p className="text-[13px] text-text-3 mt-1.5 max-w-xl">
|
|
{isTauri
|
|
? 'Items saved locally for offline playback.'
|
|
: 'Downloads are stored in the browser cache for offline playback.'}
|
|
</p>
|
|
</div>
|
|
{grouped.done.length > 0 && (
|
|
<button
|
|
onClick={clearCompleted}
|
|
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-error/50 hover:text-error transition-all focus-ring"
|
|
>
|
|
<Trash2 size={13} stroke={2} />
|
|
Clear completed
|
|
</button>
|
|
)}
|
|
</header>
|
|
|
|
{items.length === 0 && (
|
|
<div className="rounded-xl bg-elevated/30 border border-border p-10 text-center max-w-xl">
|
|
<div className="relative w-14 h-14 mx-auto mb-4">
|
|
<div className="absolute inset-0 rounded-full bg-accent/10 blur-xl" />
|
|
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-border grid place-items-center">
|
|
<Download size={20} className="text-accent" />
|
|
</div>
|
|
</div>
|
|
<p className="text-[15px] text-text-1 font-semibold tracking-tight mb-1">No downloads yet</p>
|
|
<p className="text-[12.5px] text-text-3 max-w-sm mx-auto leading-relaxed">
|
|
Use the download button in the player to save items for offline viewing.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-8">
|
|
{grouped.active.length > 0 && (
|
|
<section>
|
|
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight flex items-center gap-2">
|
|
<Clock size={13} className="text-accent" /> In progress
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{grouped.active.map(item => (
|
|
<DownloadRow key={item.id} item={item} onRemove={() => remove(item.id)} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{grouped.done.length > 0 && (
|
|
<section>
|
|
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight flex items-center gap-2">
|
|
<Check size={13} className="text-success" /> Completed
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{grouped.done.map(item => (
|
|
<DownloadRow key={item.id} item={item} onRemove={() => remove(item.id)} onPlay={() => navigate(`/play/${item.itemId}`)} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{grouped.error.length > 0 && (
|
|
<section>
|
|
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight flex items-center gap-2">
|
|
<AlertCircle size={13} className="text-error" /> Failed
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{grouped.error.map(item => (
|
|
<DownloadRow key={item.id} item={item} onRemove={() => remove(item.id)} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DownloadRow({
|
|
item,
|
|
onRemove,
|
|
onPlay,
|
|
}: {
|
|
item: import('../stores/downloads-store').DownloadItem
|
|
onRemove: () => void
|
|
onPlay?: () => void
|
|
}) {
|
|
return (
|
|
<motion.div
|
|
layout
|
|
initial={{ opacity: 0, y: 6 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="flex items-center gap-3 p-3 rounded-xl bg-elevated/30 ring-1 ring-border hover:ring-border-strong transition"
|
|
>
|
|
{item.posterUrl ? (
|
|
<img src={item.posterUrl} alt="" className="w-10 aspect-[2/3] rounded object-cover shrink-0 bg-void" loading="lazy" />
|
|
) : (
|
|
<div className="w-10 aspect-[2/3] rounded bg-void grid place-items-center shrink-0">
|
|
<Download size={14} className="text-text-4" />
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[13px] text-text-1 font-medium truncate">{item.name}</p>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
{item.status === 'downloading' && (
|
|
<>
|
|
<div className="flex-1 h-1.5 rounded-full bg-elevated overflow-hidden max-w-[160px]">
|
|
<div className="h-full bg-accent transition-[width] duration-300" style={{ width: `${item.progress}%` }} />
|
|
</div>
|
|
<span className="text-[10.5px] text-text-3 tabular-nums">{item.progress}%</span>
|
|
</>
|
|
)}
|
|
{item.status === 'done' && <span className="text-[10.5px] text-success font-medium">Ready</span>}
|
|
{item.status === 'error' && <span className="text-[10.5px] text-error font-medium truncate">{item.error || 'Failed'}</span>}
|
|
{item.status === 'queued' && <span className="text-[10.5px] text-text-3 font-medium">Queued</span>}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{onPlay && (
|
|
<button
|
|
onClick={onPlay}
|
|
className="w-8 h-8 grid place-items-center rounded-full text-text-3 hover:text-accent hover:bg-elevated transition focus-ring"
|
|
aria-label="Play"
|
|
>
|
|
<Play size={14} fill="currentColor" />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={onRemove}
|
|
className="w-8 h-8 grid place-items-center rounded-full text-text-3 hover:text-error hover:bg-elevated transition focus-ring"
|
|
aria-label="Remove"
|
|
>
|
|
<Trash2 size={13} stroke={2} />
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|