slskd: /soulseek search + downloads on the Workspace/Panel framework
Two panels (search + transfers) via WorkspaceView, coordinating over the soulseek:refresh channel. Adds the page-title rule and documents the route conventions in CLAUDE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -183,6 +183,21 @@ type parameter and let inference flow from it.
|
|||||||
|
|
||||||
Commit messages: simple lowercase, no prefixes.
|
Commit messages: simple lowercase, no prefixes.
|
||||||
|
|
||||||
|
## Frontend route conventions (apply to EVERY new dashboard route)
|
||||||
|
|
||||||
|
Worked examples: `/soulseek`, `/music`, `/chat`.
|
||||||
|
|
||||||
|
- **Workspace/Panel framework, always — never a standalone single screen.** The screen renders
|
||||||
|
`<WorkspaceView workspace={ws} locked />` where
|
||||||
|
`ws = useDashboardState<LayoutNode>('screens/<name>', defaultLayout)`, guarded by a `normalizeLayout`
|
||||||
|
that pins `appType`s to an allow-list. Panels are windowed apps under
|
||||||
|
`src/workspaces/officerdev/src/apps/<Feature>/`, each exporting `appRegistryMetas`
|
||||||
|
(`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`.
|
||||||
|
Panels coordinate via `usePanelChannel`.
|
||||||
|
- **Page title by route.** Add a rule to `RULES` in `src/apps/officer-web/state/usePageTitle.ts`
|
||||||
|
(`{ match: (p) => p.startsWith('/<name>'), title: '<Name>' }`, most-specific first);
|
||||||
|
`usePageTitleSync` does the rest.
|
||||||
|
|
||||||
## Further Reading
|
## Further Reading
|
||||||
|
|
||||||
- `CONVENTIONS.md` — component organisation, state management, React patterns, with rationale
|
- `CONVENTIONS.md` — component organisation, state management, React patterns, with rationale
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -41,6 +41,7 @@ export function App() {
|
|||||||
<Route path="/plans" element={<Dashboard.Plans />} />
|
<Route path="/plans" element={<Dashboard.Plans />} />
|
||||||
<Route path="/files" element={<Dashboard.FilesScreen />} />
|
<Route path="/files" element={<Dashboard.FilesScreen />} />
|
||||||
<Route path="/music" element={<Dashboard.MusicScreen />} />
|
<Route path="/music" element={<Dashboard.MusicScreen />} />
|
||||||
|
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
||||||
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
|
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
|
||||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import type { LucideIcon } from 'lucide-react';
|
|||||||
export type DockItem = {
|
export type DockItem = {
|
||||||
label: string;
|
label: string;
|
||||||
to: string;
|
to: string;
|
||||||
icon: LucideIcon;
|
|
||||||
color: string;
|
color: string;
|
||||||
|
// Either a lucide glyph (rendered white on the coloured tile) or an image asset (e.g. an app favicon).
|
||||||
|
icon?: LucideIcon;
|
||||||
|
image?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DockProps = {
|
type DockProps = {
|
||||||
@@ -99,7 +101,11 @@ export const Dock = ({ items, className, boundaryRef }: DockProps) => {
|
|||||||
boxShadow: active ? `0 0 12px ${item.color}40` : 'none',
|
boxShadow: active ? `0 0 12px ${item.color}40` : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<item.icon className="h-5 w-5 md:h-6 md:w-6 text-white" />
|
{item.image ? (
|
||||||
|
<img src={item.image} alt="" className="h-7 w-7 md:h-8 md:w-8 object-contain" />
|
||||||
|
) : (
|
||||||
|
item.icon && <item.icon className="h-5 w-5 md:h-6 md:w-6 text-white" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{active && (
|
{active && (
|
||||||
<div className="absolute -bottom-1.5 w-1.5 h-1.5 rounded-full" style={{ background: item.color }} />
|
<div className="absolute -bottom-1.5 w-1.5 h-1.5 rounded-full" style={{ background: item.color }} />
|
||||||
@@ -136,6 +142,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
|
|||||||
{ label: 'Email', to: '/email', icon: Mail, color: '#ef4444' },
|
{ label: 'Email', to: '/email', icon: Mail, color: '#ef4444' },
|
||||||
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||||
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
|
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
|
||||||
|
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
|
||||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||||
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
||||||
|
|||||||
@@ -118,7 +118,11 @@ export function Header({ dockItems }: HeaderProps) {
|
|||||||
boxShadow: active ? `0 0 12px ${item.color}40` : 'none',
|
boxShadow: active ? `0 0 12px ${item.color}40` : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<item.icon className="h-4 w-4 text-white" />
|
{item.image ? (
|
||||||
|
<img src={item.image} alt="" className="h-5 w-5 object-contain" />
|
||||||
|
) : (
|
||||||
|
item.icon && <item.icon className="h-4 w-4 text-white" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-sm ${active ? 'text-white font-medium' : 'text-white/80'}`}>
|
<span className={`text-sm ${active ? 'text-white font-medium' : 'text-white/80'}`}>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
import { WorkspaceView } from 'officerdev';
|
||||||
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
|
// /soulseek uses the Workspace/Panel system (like /chat and /music): two panels — the search browser
|
||||||
|
// (soulseek-search) and the download queue (soulseek-transfers) — coordinating via the 'soulseek:refresh'
|
||||||
|
// panel channel. Both talk to slskd through the /api/slskd auth proxy.
|
||||||
|
|
||||||
|
const ALLOWED_APP_TYPES = new Set<string | null>(['soulseek-search', 'soulseek-transfers', null]);
|
||||||
|
|
||||||
|
function normalizeLayout(node: LayoutNode): LayoutNode {
|
||||||
|
if (node.type === 'panel') {
|
||||||
|
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'soulseek-search' };
|
||||||
|
}
|
||||||
|
const children = node.children.map((c) => {
|
||||||
|
const fixed = normalizeLayout(c.node);
|
||||||
|
return fixed === c.node ? c : { ...c, node: fixed };
|
||||||
|
});
|
||||||
|
const changed = children.some((c, i) => c !== node.children[i]);
|
||||||
|
return changed ? { ...node, children } : node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SoulseekScreen = () => {
|
||||||
|
const rawWorkspace = useDashboardState<LayoutNode>('screens/soulseek', defaultLayout);
|
||||||
|
|
||||||
|
const workspace = useMemo(() => {
|
||||||
|
const fixed = normalizeLayout(rawWorkspace.value);
|
||||||
|
if (fixed === rawWorkspace.value) return rawWorkspace;
|
||||||
|
return { ...rawWorkspace, value: fixed };
|
||||||
|
}, [rawWorkspace]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
||||||
|
rawWorkspace.setValue(workspace.value);
|
||||||
|
}
|
||||||
|
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full pt-2">
|
||||||
|
<WorkspaceView workspace={workspace} locked />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
|
||||||
|
export const defaultLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'soulseek-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'soulseek-search', appType: 'soulseek-search' }, size: 55 },
|
||||||
|
{ node: { type: 'panel', id: 'soulseek-transfers', appType: 'soulseek-transfers' }, size: 45 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './SoulseekScreen';
|
||||||
@@ -11,6 +11,7 @@ export * from './Tasks';
|
|||||||
|
|
||||||
export * from './Files';
|
export * from './Files';
|
||||||
export * from './Music';
|
export * from './Music';
|
||||||
|
export * from './Soulseek';
|
||||||
export * from './SystemMonitor';
|
export * from './SystemMonitor';
|
||||||
export * from './Activity';
|
export * from './Activity';
|
||||||
export * from './CodeEditor';
|
export * from './CodeEditor';
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const RULES: TitleRule[] = [
|
|||||||
{ match: (p) => p.startsWith('/email'), title: 'Email' },
|
{ match: (p) => p.startsWith('/email'), title: 'Email' },
|
||||||
{ match: (p) => p.startsWith('/files'), title: 'Files' },
|
{ match: (p) => p.startsWith('/files'), title: 'Files' },
|
||||||
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
||||||
|
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||||
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
|
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
|
||||||
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
|
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
|
||||||
{ match: (p) => p.startsWith('/projects'), title: 'Projects' },
|
{ match: (p) => p.startsWith('/projects'), title: 'Projects' },
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { appRegistryMetas as previewMetas } from '../apps/Preview';
|
|||||||
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||||
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
||||||
import { appRegistryMetas as musicMetas } from '../apps/Music';
|
import { appRegistryMetas as musicMetas } from '../apps/Music';
|
||||||
|
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
||||||
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||||
import { useAppRegistry } from './useAppRegistry';
|
import { useAppRegistry } from './useAppRegistry';
|
||||||
import { useUserApps } from 'state/useUserApps';
|
import { useUserApps } from 'state/useUserApps';
|
||||||
@@ -18,7 +19,7 @@ import { createUserAppPanel } from '../apps/UserApp/UserAppPanel';
|
|||||||
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
|
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
|
||||||
import { resolveIcon } from '../utils/resolve-icon';
|
import { resolveIcon } from '../utils/resolve-icon';
|
||||||
|
|
||||||
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas, ...desktopMetas, ...musicMetas, ...monitorMetas];
|
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas, ...desktopMetas, ...musicMetas, ...soulseekMetas, ...monitorMetas];
|
||||||
|
|
||||||
export const AppRegistry = () => {
|
export const AppRegistry = () => {
|
||||||
const { registerApp } = useAppRegistry(apps);
|
const { registerApp } = useAppRegistry(apps);
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Search, Download, Lock, Loader2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
SLSKD_REFRESH_CHANNEL,
|
||||||
|
basename,
|
||||||
|
extOf,
|
||||||
|
formatSize,
|
||||||
|
type ResultRow,
|
||||||
|
type SlskdResponse,
|
||||||
|
type SlskdSearch,
|
||||||
|
} from './shared';
|
||||||
|
|
||||||
|
// Left panel of the /soulseek workspace — search slskd and enqueue downloads. A search is a create →
|
||||||
|
// poll-until-complete → fetch-responses cycle (slskd resolves in ~1-2s). Responses arrive grouped by
|
||||||
|
// peer and can be huge (hundreds of users, tens of thousands of files), so we flatten, rank by
|
||||||
|
// availability, and cap the render. Queuing a download bumps the refresh channel so the transfers
|
||||||
|
// panel (right) reflects it immediately.
|
||||||
|
|
||||||
|
const MAX_ROWS = 200;
|
||||||
|
const POLL_MS = 600;
|
||||||
|
const POLL_TIMEOUT_MS = 20_000;
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const rowKey = (row: ResultRow) => `${row.username}::${row.filename}`;
|
||||||
|
|
||||||
|
export const SoulseekSearch = () => {
|
||||||
|
const client = useClient();
|
||||||
|
const [, bumpRefresh] = usePanelChannel<number>(SLSKD_REFRESH_CHANNEL, 0);
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [rows, setRows] = useState<ResultRow[]>([]);
|
||||||
|
const [meta, setMeta] = useState<{ total: number; users: number } | null>(null);
|
||||||
|
const [queued, setQueued] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const runSearch = async (ev: React.FormEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const text = query.trim();
|
||||||
|
if (!text || searching) return;
|
||||||
|
|
||||||
|
setSearching(true);
|
||||||
|
setError(null);
|
||||||
|
setRows([]);
|
||||||
|
setMeta(null);
|
||||||
|
setQueued(new Set());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await client.post<SlskdSearch>('/slskd/api/v0/searches', { searchText: text });
|
||||||
|
const id = created.id;
|
||||||
|
|
||||||
|
let state = created;
|
||||||
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||||
|
while (!state.isComplete && Date.now() < deadline) {
|
||||||
|
await sleep(POLL_MS);
|
||||||
|
state = await client.get<SlskdSearch>(`/slskd/api/v0/searches/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = await client.get<SlskdResponse[]>(`/slskd/api/v0/searches/${id}/responses`);
|
||||||
|
|
||||||
|
const flat: ResultRow[] = [];
|
||||||
|
for (const resp of responses) {
|
||||||
|
for (const file of resp.files ?? []) {
|
||||||
|
flat.push({
|
||||||
|
username: resp.username,
|
||||||
|
filename: file.filename,
|
||||||
|
size: file.size,
|
||||||
|
extension: extOf(file),
|
||||||
|
isLocked: !!file.isLocked,
|
||||||
|
bitRate: file.bitRate,
|
||||||
|
hasFreeUploadSlot: resp.hasFreeUploadSlot,
|
||||||
|
queueLength: resp.queueLength,
|
||||||
|
uploadSpeed: resp.uploadSpeed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank the most-downloadable first: free upload slot, then unlocked, then faster peer, then size.
|
||||||
|
flat.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(b.hasFreeUploadSlot) - Number(a.hasFreeUploadSlot) ||
|
||||||
|
Number(a.isLocked) - Number(b.isLocked) ||
|
||||||
|
b.uploadSpeed - a.uploadSpeed ||
|
||||||
|
b.size - a.size,
|
||||||
|
);
|
||||||
|
|
||||||
|
setMeta({ total: flat.length, users: responses.length });
|
||||||
|
setRows(flat.slice(0, MAX_ROWS));
|
||||||
|
|
||||||
|
// slskd retains searches server-side; drop this one now that we have its responses.
|
||||||
|
client.delete(`/slskd/api/v0/searches/${id}`).catch(() => {});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = async (row: ResultRow) => {
|
||||||
|
const key = rowKey(row);
|
||||||
|
try {
|
||||||
|
await client.post(`/slskd/api/v0/transfers/downloads/${encodeURIComponent(row.username)}`, [
|
||||||
|
{ filename: row.filename, size: row.size },
|
||||||
|
]);
|
||||||
|
setQueued((prev) => new Set(prev).add(key));
|
||||||
|
bumpRefresh(Date.now());
|
||||||
|
toast.success(`Queued ${basename(row.filename)}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
<div className="shrink-0 border-b p-3">
|
||||||
|
<div className="mb-3 flex items-center gap-2 px-1">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white">
|
||||||
|
<img src="/slskd.png" alt="" className="h-5 w-5 object-contain" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-base font-semibold leading-tight">Soulseek</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">Search the Soulseek network</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={runSearch} className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(ev) => setQuery(ev.target.value)}
|
||||||
|
placeholder="Artists, albums, tracks…"
|
||||||
|
className="w-full rounded-md border bg-background py-2 pl-9 pr-3 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={searching || !query.trim()}
|
||||||
|
className="flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && <p className="mt-2 text-sm text-red-500">Search failed: {error}</p>}
|
||||||
|
{meta && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{meta.total.toLocaleString()} files from {meta.users.toLocaleString()} users
|
||||||
|
{meta.total > rows.length && ` — showing the top ${rows.length}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="divide-y">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const key = rowKey(row);
|
||||||
|
const isQueued = queued.has(key);
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center gap-3 px-3 py-2.5 text-sm">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 truncate font-medium">
|
||||||
|
{row.isLocked && <Lock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
|
||||||
|
<span className="truncate">{basename(row.filename)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
|
<span className="truncate">{row.username}</span>
|
||||||
|
<span>· {formatSize(row.size)}</span>
|
||||||
|
{row.extension && <span>· {row.extension}</span>}
|
||||||
|
{row.bitRate ? <span>· {row.bitRate} kbps</span> : null}
|
||||||
|
{row.hasFreeUploadSlot ? (
|
||||||
|
<span className="text-green-600">· free slot</span>
|
||||||
|
) : (
|
||||||
|
<span>· queue {row.queueLength}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => download(row)}
|
||||||
|
disabled={row.isLocked || isQueued}
|
||||||
|
className="flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
{isQueued ? 'Queued' : 'Download'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!searching && !error && meta && rows.length === 0 && (
|
||||||
|
<p className="p-4 text-sm text-muted-foreground">No results.</p>
|
||||||
|
)}
|
||||||
|
{!meta && !searching && !error && (
|
||||||
|
<p className="p-4 text-sm text-muted-foreground">Search for something to get started.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
|
import { RefreshCw, X, Radio } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
SLSKD_REFRESH_CHANNEL,
|
||||||
|
basename,
|
||||||
|
formatSize,
|
||||||
|
formatSpeed,
|
||||||
|
transferPhase,
|
||||||
|
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),
|
||||||
|
// flattened, and ordered active-first. 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 };
|
||||||
|
|
||||||
|
const PHASE_ORDER: Record<TransferPhase, number> = { downloading: 0, queued: 1, failed: 2, done: 3 };
|
||||||
|
|
||||||
|
const phaseStyle: Record<TransferPhase, { label: string; dot: string; bar: string }> = {
|
||||||
|
downloading: { label: 'Downloading', dot: 'bg-blue-500', bar: 'bg-blue-500' },
|
||||||
|
queued: { label: 'Queued', dot: 'bg-amber-500', bar: 'bg-amber-400' },
|
||||||
|
done: { label: 'Done', dot: 'bg-green-500', bar: 'bg-green-500' },
|
||||||
|
failed: { label: 'Failed', dot: 'bg-red-500', bar: 'bg-red-500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const flatten = (users: SlskdDownloadUser[]): Row[] => {
|
||||||
|
const rows: Row[] = [];
|
||||||
|
for (const user of users) {
|
||||||
|
for (const dir of user.directories ?? []) {
|
||||||
|
for (const file of dir.files ?? []) {
|
||||||
|
rows.push({ ...file, phase: transferPhase(file.state) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.sort((a, b) => PHASE_ORDER[a.phase] - PHASE_ORDER[b.phase] || b.percentComplete - a.percentComplete);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SoulseekTransfers = () => {
|
||||||
|
const client = useClient();
|
||||||
|
const [refresh] = usePanelChannel<number>(SLSKD_REFRESH_CHANNEL, 0);
|
||||||
|
|
||||||
|
const [app, setApp] = useState<SlskdApplication | null>(null);
|
||||||
|
const [statusError, setStatusError] = useState<string | null>(null);
|
||||||
|
const [statusLoading, setStatusLoading] = useState(true);
|
||||||
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
|
|
||||||
|
const loadStatus = useCallback(() => {
|
||||||
|
setStatusLoading(true);
|
||||||
|
setStatusError(null);
|
||||||
|
client
|
||||||
|
.get<SlskdApplication>('/slskd/api/v0/application')
|
||||||
|
.then((data) => setApp(data))
|
||||||
|
.catch((err) => setStatusError(err instanceof Error ? err.message : String(err)))
|
||||||
|
.finally(() => setStatusLoading(false));
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
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<SlskdDownloadUser[]>('/slskd/api/v0/transfers/downloads')
|
||||||
|
.then((users) => !cancelled && setRows(flatten(users)))
|
||||||
|
.catch(() => {});
|
||||||
|
tick();
|
||||||
|
const timer = setInterval(tick, POLL_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [client, refresh]);
|
||||||
|
|
||||||
|
// 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 = row.phase === 'done' || row.phase === 'failed';
|
||||||
|
try {
|
||||||
|
await client.delete(
|
||||||
|
`/slskd/api/v0/transfers/downloads/${encodeURIComponent(row.username)}/${row.id}${done ? '?remove=true' : ''}`,
|
||||||
|
);
|
||||||
|
setRows((prev) => prev.filter((r) => r.id !== row.id));
|
||||||
|
} catch {
|
||||||
|
/* next poll reconciles */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = app?.server;
|
||||||
|
const connected = server?.isConnected ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b p-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||||
|
<Radio className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
{statusLoading && !app ? (
|
||||||
|
<span className="text-muted-foreground">Checking slskd…</span>
|
||||||
|
) : statusError ? (
|
||||||
|
<span className="truncate text-red-500">slskd unreachable: {statusError}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||||
|
<span className="truncate">{server?.state ?? 'unknown'}</span>
|
||||||
|
{server?.address && <span className="truncate text-muted-foreground">· {server.address}</span>}
|
||||||
|
{app?.version?.current && <span className="shrink-0 text-muted-foreground">· v{app.version.current}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadStatus}
|
||||||
|
title="Refresh status"
|
||||||
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3.5 w-3.5 ${statusLoading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 px-3 pt-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Downloads {rows.length > 0 && `(${rows.length})`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No downloads yet. Search and queue a file to see it here.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const style = phaseStyle[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 (
|
||||||
|
<div key={row.id} className="rounded-lg border p-2.5 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`h-2 w-2 shrink-0 rounded-full ${style.dot}`} />
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium">{basename(row.filename)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(row)}
|
||||||
|
title={row.phase === 'downloading' || row.phase === 'queued' ? 'Cancel' : 'Clear'}
|
||||||
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className={`h-full ${style.bar}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
|
||||||
|
<span className="truncate">{row.username}</span>
|
||||||
|
<span>· {style.label}</span>
|
||||||
|
{row.size > 0 && <span>· {formatSize(row.size)}</span>}
|
||||||
|
{speed && <span>· {speed}</span>}
|
||||||
|
{row.phase === 'queued' && row.placeInQueue ? <span>· #{row.placeInQueue} in queue</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||||
|
import { Search, ArrowDownToLine } from 'lucide-react';
|
||||||
|
import { SoulseekSearch } from './SoulseekSearch';
|
||||||
|
import { SoulseekTransfers } from './SoulseekTransfers';
|
||||||
|
|
||||||
|
export { SoulseekSearch, SoulseekTransfers };
|
||||||
|
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{ key: 'soulseek-search', name: 'Search', icon: Search, component: SoulseekSearch, availableOnPanel: false },
|
||||||
|
{ key: 'soulseek-transfers', name: 'Transfers', icon: ArrowDownToLine, component: SoulseekTransfers, availableOnPanel: false },
|
||||||
|
];
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Shared types/helpers for the /soulseek workspace panels (SoulseekSearch + SoulseekTransfers), which
|
||||||
|
// talk to slskd through the /api/slskd auth proxy and coordinate over a single panel channel.
|
||||||
|
|
||||||
|
// Bumped (to a fresh nonce) whenever a download is enqueued from the search panel, so the transfers
|
||||||
|
// panel refetches immediately instead of waiting for its next poll tick.
|
||||||
|
export const SLSKD_REFRESH_CHANNEL = 'soulseek:refresh';
|
||||||
|
|
||||||
|
export type SlskdApplication = {
|
||||||
|
version?: { current?: string; full?: string; latest?: string; isUpdateAvailable?: boolean };
|
||||||
|
server?: { state?: string; address?: string; isConnected?: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SlskdSearch = {
|
||||||
|
id: string;
|
||||||
|
isComplete: boolean;
|
||||||
|
state: string;
|
||||||
|
fileCount: number;
|
||||||
|
responseCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SlskdFile = {
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
extension?: string;
|
||||||
|
isLocked?: boolean;
|
||||||
|
bitRate?: number;
|
||||||
|
length?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SlskdResponse = {
|
||||||
|
username: string;
|
||||||
|
fileCount: number;
|
||||||
|
files: SlskdFile[];
|
||||||
|
hasFreeUploadSlot: boolean;
|
||||||
|
queueLength: number;
|
||||||
|
uploadSpeed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One flat, rankable search result (a single file from a single peer).
|
||||||
|
export type ResultRow = {
|
||||||
|
username: string;
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
extension: string;
|
||||||
|
isLocked: boolean;
|
||||||
|
bitRate?: number;
|
||||||
|
hasFreeUploadSlot: boolean;
|
||||||
|
queueLength: number;
|
||||||
|
uploadSpeed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A download transfer. GET /transfers/downloads nests these as user → directories → files.
|
||||||
|
export type SlskdTransfer = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
direction: string;
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
state: string;
|
||||||
|
bytesTransferred: number;
|
||||||
|
averageSpeed: number;
|
||||||
|
percentComplete: number;
|
||||||
|
placeInQueue?: number;
|
||||||
|
};
|
||||||
|
export type SlskdDownloadDirectory = { directory: string; fileCount: number; files: SlskdTransfer[] };
|
||||||
|
export type SlskdDownloadUser = { username: string; directories: SlskdDownloadDirectory[] };
|
||||||
|
|
||||||
|
export const basename = (path: string) => path.split(/[\\/]/).pop() ?? path;
|
||||||
|
|
||||||
|
export const formatSize = (bytes: number) => {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||||
|
return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatSpeed = (bytesPerSec: number) => (bytesPerSec > 0 ? `${formatSize(bytesPerSec)}/s` : '');
|
||||||
|
|
||||||
|
export const extOf = (file: SlskdFile) =>
|
||||||
|
(file.extension?.trim() || basename(file.filename).split('.').pop() || '').toLowerCase();
|
||||||
|
|
||||||
|
// slskd serialises TransferStates as a compound flag string ("Completed, Succeeded", "Queued, Remotely",
|
||||||
|
// "InProgress", "Requested", …). Collapse it to the one bucket we render.
|
||||||
|
export type TransferPhase = 'downloading' | 'queued' | 'done' | 'failed';
|
||||||
|
export const transferPhase = (state: string): TransferPhase => {
|
||||||
|
const s = state.toLowerCase();
|
||||||
|
if (s.includes('inprogress') || s.includes('initializing')) return 'downloading';
|
||||||
|
if (s.includes('succeeded')) return 'done';
|
||||||
|
if (s.includes('errored') || s.includes('cancelled') || s.includes('rejected') || s.includes('timedout') || s.includes('aborted'))
|
||||||
|
return 'failed';
|
||||||
|
return 'queued'; // Requested / Queued / anything not yet resolved
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user