From 2a509646e7a25f2d1d50c85ac4c6fb77261fa48b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 29 Jul 2026 17:21:29 +0000 Subject: [PATCH] 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 --- CLAUDE.md | 15 ++ public/slskd.png | Bin 0 -> 1634 bytes src/apps/officer-web/App.tsx | 1 + .../Screens/Dashboard/Layout/Dock.tsx | 11 +- .../Dashboard/Layout/Header/Header.tsx | 6 +- .../Dashboard/Soulseek/SoulseekScreen.tsx | 45 ++++ .../Dashboard/Soulseek/defaultLayout.ts | 11 + .../Screens/Dashboard/Soulseek/index.tsx | 1 + .../officer-web/Screens/Dashboard/index.tsx | 1 + src/apps/officer-web/state/usePageTitle.ts | 1 + .../src/AppRegistry/AppRegistry.tsx | 3 +- .../src/apps/Soulseek/SoulseekSearch.tsx | 206 ++++++++++++++++++ .../src/apps/Soulseek/SoulseekTransfers.tsx | 177 +++++++++++++++ .../officerdev/src/apps/Soulseek/index.ts | 11 + .../officerdev/src/apps/Soulseek/shared.ts | 92 ++++++++ 15 files changed, 577 insertions(+), 4 deletions(-) create mode 100644 public/slskd.png create mode 100644 src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Soulseek/defaultLayout.ts create mode 100644 src/apps/officer-web/Screens/Dashboard/Soulseek/index.tsx create mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SoulseekSearch.tsx create mode 100644 src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx create mode 100644 src/workspaces/officerdev/src/apps/Soulseek/index.ts create mode 100644 src/workspaces/officerdev/src/apps/Soulseek/shared.ts diff --git a/CLAUDE.md b/CLAUDE.md index 558fcf55..5438b43a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,6 +183,21 @@ type parameter and let inference flow from it. 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 + `` where + `ws = useDashboardState('screens/', defaultLayout)`, guarded by a `normalizeLayout` + that pins `appType`s to an allow-list. Panels are windowed apps under + `src/workspaces/officerdev/src/apps//`, 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('/'), title: '' }`, most-specific first); + `usePageTitleSync` does the rest. + ## Further Reading - `CONVENTIONS.md` — component organisation, state management, React patterns, with rationale diff --git a/public/slskd.png b/public/slskd.png new file mode 100644 index 0000000000000000000000000000000000000000..0b82062ccf6c1807f09a4ef89650ecefffc501cb GIT binary patch literal 1634 zcmV-o2A%ndP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00006VoOIv0RI600RN!9r;`8x z1?x#fK~!ko?bv&emsK6d@z?pmN(>U3Ogd@Mq{Tl?BuUPsgHnoPiU<+Hh$tdsUMj;% z88TM_GfHO^%~>tO1k`oFQ8YEE1@lsh;@Cwg#%f~3QlViCWp{t)+aLSz+dHrq*qq@r zv$K1?=j?OdpL6{_4+nHWJyPnko+FZ*eNx#cgrk%Vkqs6?ct^HX+27Rrlk3x#zBh1F z4D?$u)@5=tY3cf6cE4xZ|p)jCD&FgqncwDwda+YvU z4|>{!vMML_j)CQII2OpxM^z=BtQBsx#Y3u7V(Tx_QS2{xI{tVQ{G2XINIKEBI zdc&P(Y>N?ZsYVxRIZK`EIpGr3>BZeODs>C~!MH#_8>qm)3&Kv!>=gc%7$97_$LA-D zeb|Y6f~Tu9P#q=5TBPjC%6KN<$0$rt@^$BSuxy%pn<{%lSSM_eye$l)Vk+Zu^V02m zi?sK9;E5Pyr)9DU#TtDLQ}y?%9p1p*rlmcPZcE)32Yb&`vXR9NJj+$-=UsWk-Yuck zF+_v4m{3I4N3Q#(h5KxDd6(d;qom$)j4UEocNKo`4AjOz^&ZA?MP!LLjWa2| zttP4@huCPg>Xf_G^P#T6co2T-J84l@;rDD{eN;JEEyjT4E-#to;?w{J8R!-BJA{wu ze0kmyr{H@<=|wwytvm4SX%oNY2FaNMEcHU0k)EEmw`#vUKCRI#*>hOl;WKZKBQ;o7 z4Bj@`WYy`t?i%QPlztWCimN{f&tj}ErJWtNy*Ex4u9J`!4BZcWqi67) z8z}oOjsW3OXQVgUjpR`CWLFnS+l#UZrl-xF_EZyJQFcX9`n4k}d!R>Zzl2uDFv(wp z0kRcNOOspGKNv>~_X?wnyk)hWCYq7{yBqjX!jrN=vd#MWtV`0rd&5BcQ>dS-FaTxQ zyjFFq;%vzVj4mP%8EpJ+;qQu%;$aNJPE6@7`1S_YM-|Q~O7}ZGZEOKQM_7TuMP#|5 zCU?5A;m$Z**=iiADJ7oi^7L$P!M8WC-8dWuk|mY+qj8hu#un}G^oQMKoV(yl)?h>t znd`E2Z*RiWb`zJJr0JC3YOCLX$KpoWH(Rt?Vxo&pN>Pa{jSniBCi|l77}-I>VRCU0 zu+Uf24|^NFy@BS@ddZYj_Czd@&1q4;$`G@q0zM+S-+96!;qVT72=iT>uH6^#v^|B^ z#tX7hvL#rf>>gPvvd^dNFwT_Jl>663e{Z=}_Ho%WvVRKGWhWQ2*eJWqh3Src1>eyO z>^e<8BTQ{!@Xs2IufW5bKBmE3=ca9H;~?3OaB@+*LzwEqv}#|%cVysS6$8!Mu4Iyl z=|8PnPDz`6Mr%-sbO?_ZytK)Q+YU4v5h}$H;!?8v5 z35`bWFL>IYLa!;eiu~B=X=SU(%J>v!3bWo=^VPJ#Dd}a^QId=iUch!?qp;pVRys3n>r3!vYFf2-t~I0nNs<^SL6YoM+h z2qzYQ|H9bxYF`6&-2k6Qy|Vdz4bxo%BZNDQ?VF3JHw+xCzZ?1*r2Bd%-gg_fwd&%K gzD78p1L{Nl57{ed-f}^y-2eap07*qoM6N<$f<|s9d;kCd literal 0 HcmV?d00001 diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 980be5d4..22e3fbf4 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -41,6 +41,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index 7205d8e6..714134dc 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -5,8 +5,10 @@ import type { LucideIcon } from 'lucide-react'; export type DockItem = { label: string; to: string; - icon: LucideIcon; 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 = { @@ -99,7 +101,11 @@ export const Dock = ({ items, className, boundaryRef }: DockProps) => { boxShadow: active ? `0 0 12px ${item.color}40` : 'none', }} > - + {item.image ? ( + + ) : ( + item.icon && + )} {active && (
@@ -136,6 +142,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [ { label: 'Email', to: '/email', icon: Mail, color: '#ef4444' }, { label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' }, { 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: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' }, { label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' }, diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx index a57f09b5..c9d40976 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx @@ -118,7 +118,11 @@ export function Header({ dockItems }: HeaderProps) { boxShadow: active ? `0 0 12px ${item.color}40` : 'none', }} > - + {item.image ? ( + + ) : ( + item.icon && + )}
{item.label} diff --git a/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx new file mode 100644 index 00000000..c045de0e --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx @@ -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(['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('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 ( +
+ +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Soulseek/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Soulseek/defaultLayout.ts new file mode 100644 index 00000000..b82856a7 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Soulseek/defaultLayout.ts @@ -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 }, + ], +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Soulseek/index.tsx b/src/apps/officer-web/Screens/Dashboard/Soulseek/index.tsx new file mode 100644 index 00000000..b6db6bc2 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Soulseek/index.tsx @@ -0,0 +1 @@ +export * from './SoulseekScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 17d8198e..3fba9919 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -11,6 +11,7 @@ export * from './Tasks'; export * from './Files'; export * from './Music'; +export * from './Soulseek'; export * from './SystemMonitor'; export * from './Activity'; export * from './CodeEditor'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index ed659c3f..31ca3248 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -16,6 +16,7 @@ const RULES: TitleRule[] = [ { match: (p) => p.startsWith('/email'), title: 'Email' }, { match: (p) => p.startsWith('/files'), title: 'Files' }, { 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('/code-editor'), title: 'Code Editor' }, { match: (p) => p.startsWith('/projects'), title: 'Projects' }, diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 0c65c211..dc020e5d 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -11,6 +11,7 @@ import { appRegistryMetas as previewMetas } from '../apps/Preview'; import { appRegistryMetas as widgetMetas } from '../apps/Widgets'; import { appRegistryMetas as desktopMetas } from '../apps/Desktop'; import { appRegistryMetas as musicMetas } from '../apps/Music'; +import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek'; import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor'; import { useAppRegistry } from './useAppRegistry'; import { useUserApps } from 'state/useUserApps'; @@ -18,7 +19,7 @@ import { createUserAppPanel } from '../apps/UserApp/UserAppPanel'; import { createUserAppHeader } from '../apps/UserApp/UserAppHeader'; 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 = () => { const { registerApp } = useAppRegistry(apps); diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekSearch.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekSearch.tsx new file mode 100644 index 00000000..46e2d83c --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekSearch.tsx @@ -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(SLSKD_REFRESH_CHANNEL, 0); + + const [query, setQuery] = useState(''); + const [searching, setSearching] = useState(false); + const [error, setError] = useState(null); + const [rows, setRows] = useState([]); + const [meta, setMeta] = useState<{ total: number; users: number } | null>(null); + const [queued, setQueued] = useState>(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('/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(`/slskd/api/v0/searches/${id}`); + } + + const responses = await client.get(`/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 ( +
+
+
+
+ +
+
+

Soulseek

+

Search the Soulseek network

+
+
+ +
+
+ + 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" + /> +
+ +
+ + {error &&

Search failed: {error}

} + {meta && ( +

+ {meta.total.toLocaleString()} files from {meta.users.toLocaleString()} users + {meta.total > rows.length && ` — showing the top ${rows.length}`} +

+ )} +
+ +
+ {rows.length > 0 && ( +
+ {rows.map((row) => { + const key = rowKey(row); + const isQueued = queued.has(key); + return ( +
+
+
+ {row.isLocked && } + {basename(row.filename)} +
+
+ {row.username} + · {formatSize(row.size)} + {row.extension && · {row.extension}} + {row.bitRate ? · {row.bitRate} kbps : null} + {row.hasFreeUploadSlot ? ( + · free slot + ) : ( + · queue {row.queueLength} + )} +
+
+ +
+ ); + })} +
+ )} + + {!searching && !error && meta && rows.length === 0 && ( +

No results.

+ )} + {!meta && !searching && !error && ( +

Search for something to get started.

+ )} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx new file mode 100644 index 00000000..209d337e --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx @@ -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 = { downloading: 0, queued: 1, failed: 2, done: 3 }; + +const phaseStyle: Record = { + 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(SLSKD_REFRESH_CHANNEL, 0); + + const [app, setApp] = useState(null); + const [statusError, setStatusError] = useState(null); + const [statusLoading, setStatusLoading] = useState(true); + const [rows, setRows] = useState([]); + + const loadStatus = useCallback(() => { + setStatusLoading(true); + setStatusError(null); + client + .get('/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('/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 ( +
+
+
+ + {statusLoading && !app ? ( + Checking slskd… + ) : statusError ? ( + slskd unreachable: {statusError} + ) : ( + <> + + {server?.state ?? 'unknown'} + {server?.address && · {server.address}} + {app?.version?.current && · v{app.version.current}} + + )} +
+ +
+ +
+ Downloads {rows.length > 0 && `(${rows.length})`} +
+ +
+ {rows.length === 0 ? ( +

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

+ ) : ( +
+ {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 ( +
+
+ + {basename(row.filename)} + +
+
+
+
+
+ {row.username} + · {style.label} + {row.size > 0 && · {formatSize(row.size)}} + {speed && · {speed}} + {row.phase === 'queued' && row.placeInQueue ? · #{row.placeInQueue} in queue : null} +
+
+ ); + })} +
+ )} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/index.ts b/src/workspaces/officerdev/src/apps/Soulseek/index.ts new file mode 100644 index 00000000..f45a7ea3 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Soulseek/index.ts @@ -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 }, +]; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts new file mode 100644 index 00000000..4091c83e --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts @@ -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 +};