import type { ReactNode } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react'; import { toast } from 'sonner'; import { useClient } from 'hooks/useClient'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { RefreshCw, X, Radio, Folder, RotateCcw, Ban, Trash2 } from 'lucide-react'; import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList } from './Cards'; import { UserMenu } from './UserMenu'; import { SLSKD_REFRESH_CHANNEL, basename, folderLabel, formatSize, formatSpeed, transferPhase, toneSolid, PHASE_TONE, type SlskdApplication, type SlskdDownloadUser, type SlskdTransfer, type TransferPhase, } from './shared'; // Right panel of the /soulseek workspace — the connection status strip plus the live download queue. // Downloads are polled from slskd (GET /transfers/downloads, nested user → directories → files) and // presented the same way as search results: a collapsible card per user, files grouped by folder. // Enqueuing from the search panel bumps the refresh channel so the list updates without waiting for the // next poll tick. const POLL_MS = 1500; type Row = SlskdTransfer & { phase: TransferPhase }; type DlDir = { directory: string; label: string; files: Row[]; size: number }; type DlUser = { username: string; dirs: DlDir[]; counts: Record; total: number }; const PHASE_ORDER: Record = { downloading: 0, queued: 1, failed: 2, done: 3 }; const PHASES: TransferPhase[] = ['downloading', 'queued', 'failed', 'done']; const PHASE_LABEL: Record = { downloading: 'Downloading', queued: 'Queued', done: 'Done', failed: 'Failed', }; const isRemovable = (phase: TransferPhase) => phase === 'done' || phase === 'failed'; // Group raw download users → dirs → files, keeping only files whose phase passes `keep`. Users/dirs // that end up empty are dropped, so passing a done/not-done predicate splits the queue into sections. const group = (users: SlskdDownloadUser[], keep: (phase: TransferPhase) => boolean): DlUser[] => { const out: DlUser[] = []; for (const user of users) { const dirs: DlDir[] = []; for (const dir of user.directories ?? []) { const files: Row[] = (dir.files ?? []) .map((f) => ({ ...f, phase: transferPhase(f.state) })) .filter((f) => keep(f.phase)); if (files.length === 0) continue; files.sort( (a, b) => PHASE_ORDER[a.phase] - PHASE_ORDER[b.phase] || basename(a.filename).localeCompare(basename(b.filename)), ); dirs.push({ directory: dir.directory, label: folderLabel(dir.directory), files, size: files.reduce((n, f) => n + f.size, 0), }); } if (dirs.length === 0) continue; dirs.sort((a, b) => a.directory.localeCompare(b.directory)); const counts: Record = { downloading: 0, queued: 0, failed: 0, done: 0 }; let total = 0; for (const dir of dirs) for (const f of dir.files) (counts[f.phase]++, total++); out.push({ username: user.username, dirs, counts, total }); } // Active users first, then alphabetical. out.sort( (a, b) => Number(b.counts.downloading > 0) - Number(a.counts.downloading > 0) || a.username.localeCompare(b.username), ); return out; }; export const SoulseekTransfers = () => { const client = useClient(); const [refresh] = usePanelChannel(SLSKD_REFRESH_CHANNEL, 0); const [app, setApp] = useState(null); const [statusError, setStatusError] = useState(null); const [statusLoading, setStatusLoading] = useState(true); const [data, setData] = useState([]); // Users are open by default; collapsing a live-updating list is opt-in so new users appear expanded. const [collapsed, setCollapsed] = useState>(new Set()); // Two sections: everything still in flight (downloading / queued / failed) on top, finished // (succeeded) collected at the bottom. A user with a mix appears in both. const active = useMemo(() => group(data, (p) => p !== 'done'), [data]); const completed = useMemo(() => group(data, (p) => p === 'done'), [data]); const doneCount = useMemo(() => completed.reduce((n, u) => n + u.total, 0), [completed]); // Queue-wide phase tallies drive the bulk toolbar. Cancelled transfers collapse to `failed`, so the // three actions target disjoint sets: retry → failed, cancel → in-flight, remove → done. const totals = useMemo(() => { const t: Record = { downloading: 0, queued: 0, failed: 0, done: 0 }; for (const u of data) for (const d of u.directories ?? []) for (const f of d.files ?? []) t[transferPhase(f.state)]++; return t; }, [data]); const inFlight = totals.downloading + totals.queued; const loadStatus = useCallback(() => { setStatusLoading(true); setStatusError(null); client .get('/soulseek/api/v0/application') .then((d) => setApp(d)) .catch((err) => setStatusError(err instanceof Error ? err.message : String(err))) .finally(() => setStatusLoading(false)); // useClient() is a fresh object each render — keep this callback stable. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { loadStatus(); }, [loadStatus]); // Poll the download queue; also refetch immediately when the search panel bumps the refresh nonce. useEffect(() => { let cancelled = false; const tick = () => client .get('/soulseek/api/v0/transfers/downloads') .then((u) => !cancelled && setData(u)) .catch(() => {}); tick(); const timer = setInterval(tick, POLL_MS); return () => { cancelled = true; clearInterval(timer); }; // useClient() is a fresh object each render — poll on the refresh nonce only. // eslint-disable-next-line react-hooks/exhaustive-deps }, [refresh]); // Collapse keys are section-qualified ("a:" / "c:") so the same peer can be open in the // active section and collapsed in completed independently. const toggle = (key: string) => setCollapsed((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); const dropIds = (ids: Set) => setData((prev) => prev.map((u) => ({ ...u, directories: (u.directories ?? []).map((d) => ({ ...d, files: (d.files ?? []).filter((f) => !ids.has(f.id)) })), })), ); // For an active transfer this cancels it; for a finished/failed one it clears it from the queue. const remove = async (row: Row) => { const done = isRemovable(row.phase); try { await client.delete( `/soulseek/api/v0/transfers/downloads/${encodeURIComponent(row.username)}/${row.id}${done ? '?remove=true' : ''}`, ); dropIds(new Set([row.id])); } catch { /* next poll reconciles */ } }; const clearCompleted = async (user: DlUser) => { const finished = user.dirs.flatMap((d) => d.files).filter((f) => isRemovable(f.phase)); if (finished.length === 0) return; await Promise.allSettled( finished.map((f) => client.delete(`/soulseek/api/v0/transfers/downloads/${encodeURIComponent(user.username)}/${f.id}?remove=true`), ), ); dropIds(new Set(finished.map((f) => f.id))); }; const clearAllCompleted = async () => { const finished = completed.flatMap((u) => u.dirs.flatMap((d) => d.files.map((f) => ({ username: u.username, id: f.id }))), ); if (finished.length === 0) return; await Promise.allSettled( finished.map((f) => client.delete(`/soulseek/api/v0/transfers/downloads/${encodeURIComponent(f.username)}/${f.id}?remove=true`), ), ); dropIds(new Set(finished.map((f) => f.id))); }; // Cancel every in-flight transfer (no ?remove — they become cancelled/failed and the poll reconciles). const cancelAll = async () => { const targets: { username: string; id: string }[] = []; for (const u of data) for (const d of u.directories ?? []) for (const f of d.files ?? []) { const p = transferPhase(f.state); if (p === 'downloading' || p === 'queued') targets.push({ username: u.username, id: f.id }); } if (targets.length === 0) return; await Promise.allSettled( targets.map((t) => client.delete(`/soulseek/api/v0/transfers/downloads/${encodeURIComponent(t.username)}/${t.id}`)), ); }; // Re-enqueue every errored/cancelled transfer, grouped into one POST per peer. const retryErrored = async () => { const byUser = new Map(); for (const u of data) for (const d of u.directories ?? []) for (const f of d.files ?? []) if (transferPhase(f.state) === 'failed') { const files = byUser.get(u.username) ?? []; files.push({ filename: f.filename, size: f.size }); byUser.set(u.username, files); } if (byUser.size === 0) return; const n = [...byUser.values()].reduce((s, a) => s + a.length, 0); try { await Promise.all( [...byUser].map(([username, files]) => client.post(`/soulseek/api/v0/transfers/downloads/${encodeURIComponent(username)}`, files), ), ); toast.success(n === 1 ? 'Retrying 1 download' : `Retrying ${n} downloads`); } catch (err) { toast.error(`Retry failed: ${err instanceof Error ? err.message : String(err)}`); } }; // Force slskd to re-request this download's place in the peer's queue, then patch the fresh value in. const refreshPosition = async (row: Row) => { try { const t = await client.get( `/soulseek/api/v0/transfers/downloads/${encodeURIComponent(row.username)}/${row.id}/position`, ); setData((prev) => prev.map((u) => u.username !== row.username ? u : { ...u, directories: (u.directories ?? []).map((d) => ({ ...d, files: (d.files ?? []).map((f) => (f.id === row.id ? { ...f, placeInQueue: t.placeInQueue } : f)), })), }, ), ); toast.success(t.placeInQueue ? `Place in queue: #${t.placeInQueue}` : 'Queue position refreshed'); } catch (err) { toast.error(`Couldn't fetch position: ${err instanceof Error ? err.message : String(err)}`); } }; const server = app?.server; const connected = server?.isConnected ?? false; return (
{statusLoading && !app ? ( Checking slskd… ) : statusError ? ( slskd unreachable: {statusError} ) : ( <> {server?.state ?? 'unknown'} {server?.address && · {server.address}} {app?.version?.current && ( · v{app.version.current} )} )}
{(active.length > 0 || completed.length > 0) && (
} label="Retry errored" count={totals.failed} onClick={retryErrored} /> } label="Cancel all" count={inFlight} onClick={cancelAll} /> } label="Remove completed" count={totals.done} onClick={clearAllCompleted} />
)}
{active.length === 0 && completed.length === 0 ? (

No downloads yet. Search and queue a file to see it here.

) : (
{active.length > 0 && (
Active ({active.length})
{active.map((user) => ( toggle(`a:${user.username}`)} onRemove={remove} onPosition={refreshPosition} onClearCompleted={() => clearCompleted(user)} /> ))}
)} {completed.length > 0 && (
Completed ({doneCount})
{completed.map((user) => ( toggle(`c:${user.username}`)} onRemove={remove} onPosition={refreshPosition} onClearCompleted={() => clearCompleted(user)} /> ))}
)}
)}
); }; type BulkButtonProps = { icon: ReactNode; label: string; count: number; onClick: () => void }; const BulkButton = ({ icon, label, count, onClick }: BulkButtonProps) => ( ); type UserCardProps = { user: DlUser; open: boolean; onToggle: () => void; onRemove: (row: Row) => void; onPosition: (row: Row) => void; onClearCompleted: () => void; }; const UserCard = ({ user, open, onToggle, onRemove, onPosition, onClearCompleted }: UserCardProps) => { const finished = user.counts.done + user.counts.failed; const meta = PHASES.filter((p) => user.counts[p] > 0).map((p) => ( {user.counts[p]} )); return ( } meta={meta} /> {open && ( {user.dirs.map((dir) => ( ))} {finished > 0 && ( )} )} ); }; type FolderBlockProps = { dir: DlDir; onRemove: (row: Row) => void; onPosition: (row: Row) => void }; const FolderBlock = ({ dir, onRemove, onPosition }: FolderBlockProps) => { const [open, setOpen] = useState(true); return ( setOpen((v) => !v)} icon={} label={dir.label} title={dir.directory} meta={`${dir.files.length} · ${formatSize(dir.size)}`} /> {open && ( {dir.files.map((row) => { const dot = toneSolid[PHASE_TONE[row.phase]]; const pct = row.phase === 'done' ? 100 : Math.max(0, Math.min(100, Math.round(row.percentComplete))); const speed = row.phase === 'downloading' ? formatSpeed(row.averageSpeed) : ''; return (
{basename(row.filename)}
{row.phase === 'queued' ? ( ) : ( {PHASE_LABEL[row.phase]} )} {row.size > 0 && · {formatSize(row.size)}} {speed && · {speed}}
); })} )} ); };