the soulseek hub: the whole Qt client as one area, wired to the wire

Kyle: "this is going to be a soulseek menu now — essentially the soulseek
app in our app." Approved against research/soulseek-search/hub2.html, a
one-to-one mock in the real frame, then built in three layers.

THE AREA. Search and Downloads leave the sidebar; one Soulseek entry
(globe) opens src/views/Soulseek.svelte, whose tab row is SoulseekQt's own
anatomy: Transfers, Search, Browse, Rooms, Chat, Users. The old view ids
stay routable — App.svelte aliases "search" and "downloads" to the hub and
the component syncs its tab off them — so the title bar's download ring,
"get on soulseek" hand-offs, history entries and restored sessions all
land on the right tab. The glass bar rides the hub with the connection
line on its right (online as X · sharing N files), the page takes Home's
below-the-bar inset, and the bar arrows join the chrome spec (16px glyphs)
alongside the sidebar toggle's disc. The replaced Search.svelte and
Downloads.svelte are deleted; Transfers carries their data forward in Qt's
own columns (User/File/Progress/Status/ETA/Speed/Size/Place, cancel and
play/reveal/add on the rows, clear-complete and open-folder in the
toolbar, live speed totals in the footer) and Search re-skins the peer
search as the Qt table: results regrouped user -> folder -> file,
slot/queue chips, format filter chips that actually filter, fold-out
files with stream-and-download on hover.

THE WIRE. soulseek-proto learns the rest of the protocol: rooms 13-17
(say/join/leave/user-joined/user-left), private messages 22/23, the
watch/status/stats family 5/6/7/36, the room list 64, and the peer-side
browse pair 4/5 with Directory::read to decode what our own responder
already encoded. Parsers are tolerant where the wire is baroque — roster
sections consume their own declared counts even when they disagree with
the name list, RoomList ignores the private-room tail, truncated stats
default instead of failing the frame. Codec tests ride along, including a
browse round-trip against our own encoder.

soulseek-client turns those into Events (RoomList/RoomJoined/RoomMessage/
PrivateMessage/UserWatched/...), auto-acks every private message the
moment it lands — unacked DMs are redelivered on every login — and grows
a spawn-shaped browse(): the returned future owns an Arc of the internals
so the caller drops the client lock before awaiting the 45s worst case.

THE APP. The event pump forwards everything as soulseek:* events; new
commands cover join/leave/say/message/room-list/browse plus a buddy list
persisted in config and re-watched on every login so status and stats
stream while connected. The frontend slsk store drains it all into the
four tabs: Rooms (joined + popular lists, live roster column, per-user
colored feed, say box — the server echoes our own lines so nothing is
appended locally), Chat (recency-sorted conversations, unread badges, DM
bubbles — our half appended locally since DMs are NOT echoed), Users
(add/remove buddies, status chips, one-click Browse/Chat cross-wiring),
and Browse (type a name, walk their tree, stream or download any file).

And the sharing side finally taps the shoulder: UploadToast announces the
first event of every new upload in the shared toast slot, folding a
same-user burst into one counting pill ("nikac is taking 12 files")
instead of twelve entrances.

Live-verified against the real network: the server handed 703 rooms after
login, and a browse of a live peer returned 3,796 directories / 34,773
files in 929ms with full paths, sizes and attributes. Join/say and DMs
are wired but deliberately unexercised — the first hello in a real room
is Kyle's to say, not a probe's.
This commit is contained in:
KeelrLeek committed 2026-08-31 15:13:53 -05:00
1 parent 913289a297
commit 63a0357651
17 files changed
+2843 -773

No files matched your search

+12
View File
@@ -139,6 +139,18 @@ export const smtcMetadata = (title, artist, album, art) =>
});
// The WebView blocks target="_blank"; external links open via the backend.
export const openExternal = (url) => invoke("open_external", { url });
// ---- the Soulseek hub: rooms / chat / buddies / browse ----
export const slskJoinRoom = (room) => invoke("slsk_join_room", { room });
export const slskLeaveRoom = (room) => invoke("slsk_leave_room", { room });
export const slskSayRoom = (room, message) => invoke("slsk_say_room", { room, message });
export const slskMessageUser = (username, message) =>
invoke("slsk_message_user", { username, message });
export const slskRoomList = () => invoke("slsk_room_list");
export const slskBrowseUser = (username) => invoke("slsk_browse_user", { username });
export const buddyList = () => invoke("buddy_list");
export const buddyAdd = (username) => invoke("buddy_add", { username });
export const buddyRemove = (username) => invoke("buddy_remove", { username });
// `maxEdge` asks the backend for a Lanczos3 thumbnail bounded to that many
// pixels on the long side. Omit it only where true pixels matter (the
// fullscreen viewer, the import picker's comparison and loupe).
+154
View File
@@ -214,6 +214,27 @@ export function announceFollow(on, artist) {
followToast.portrait = artist?.picture ?? null;
}
/** Someone on the network is taking a file from us (UploadToast). A burst
* from one user — an album grab — counts up inside one pill instead of
* stacking twelve: within the window, same user increments `count` and the
* toast extends its hold rather than replaying its entrance. */
export const uploadToast = $state({ seq: 0, username: "", name: "", count: 0 });
const UPLOAD_TOAST_WINDOW_MS = 8000;
let uploadToastAt = 0;
export function announceUpload(username, name) {
const now = Date.now();
if (uploadToast.username === username && now - uploadToastAt < UPLOAD_TOAST_WINDOW_MS) {
uploadToast.count += 1;
} else {
uploadToast.username = username;
uploadToast.count = 1;
}
uploadToast.name = name;
uploadToastAt = now;
uploadToast.seq += 1;
}
/** Background-import status, read by ImportToast. The import window keeps
* scanning/copying after it is closed (the component stays mounted, only
* its template goes), and closed must never mean invisible: while work
@@ -1700,6 +1721,7 @@ export function goToView(view, opts = {}) {
const VIEW_LABELS = {
home: "Home",
soulseek: "Soulseek",
search: "Search",
library: "Your Library",
downloads: "Downloads",
@@ -1710,6 +1732,7 @@ const VIEW_LABELS = {
};
const VIEW_GLYPHS = {
home: "home",
soulseek: "globe",
search: "search",
library: "library",
downloads: "download",
@@ -5492,6 +5515,81 @@ export const network = $state({
uploads: {}, // id -> upload payload
});
/* ------------------------------------------------ the Soulseek hub's seats
* Rooms, chat and the buddy list — fed by the room/message/user events the
* backend pump emits, drained by the hub view's Rooms/Chat/Users tabs. */
export const slsk = $state({
/** Public room list, [{name, users}] — pushed after login. */
rooms: [],
/** room → { users: [RoomUser], messages: [{username, message, ts}] }. */
joined: {},
activeRoom: null,
/** username → { messages: [{me, text, ts}], unread }. */
chats: {},
activeChat: null,
buddies: [],
/** username → { status?, avg_speed?, file_count?, dir_count?, exists? }. */
buddyState: {},
});
const SLSK_FEED_MAX = 500;
function trimFeed(arr, max) {
if (arr.length > max) arr.splice(0, arr.length - max);
}
export async function loadBuddies() {
try {
slsk.buddies = await api.buddyList();
} catch {
// backend unavailable — the Users tab renders empty and retries on
// the next connect
}
}
export async function joinRoom(name) {
await api.slskJoinRoom(name);
// The roster arrives as soulseek:room-joined; nothing optimistic here.
}
export async function leaveRoom(name) {
await api.slskLeaveRoom(name);
delete slsk.joined[name];
if (slsk.activeRoom === name) slsk.activeRoom = Object.keys(slsk.joined)[0] ?? null;
}
/** The server echoes our own line back to everyone including us, so the
* local append would double it — send only. */
export async function sayRoom(room, text) {
const t = text.trim();
if (t) await api.slskSayRoom(room, t);
}
export function openChat(username) {
slsk.chats[username] ??= { messages: [], unread: 0 };
slsk.chats[username].unread = 0;
slsk.activeChat = username;
}
/** DMs are NOT echoed back — our half is appended locally. */
export async function sendDm(username, text) {
const t = text.trim();
if (!t) return;
await api.slskMessageUser(username, t);
openChat(username);
slsk.chats[username].messages.push({ me: true, text: t, ts: Date.now() });
trimFeed(slsk.chats[username].messages, SLSK_FEED_MAX);
}
export async function addBuddy(username) {
slsk.buddies = await api.buddyAdd(username);
}
export async function removeBuddy(username) {
slsk.buddies = await api.buddyRemove(username);
delete slsk.buddyState[username];
}
const TERMINAL_TRANSFER_STATES = new Set(["complete", "failed", "cancelled"]);
const DOWNLOAD_HISTORY_MAX = 300;
const UPLOAD_HISTORY_MAX = 100;
@@ -5871,6 +5969,9 @@ export function initEvents() {
app.connection = "online";
app.username = ev.username;
app.loginError = "";
// The connect path re-watched the buddy list server-side; pull
// the list itself for the Users tab.
void loadBuddies();
} else if (ev.kind === "disconnected") {
app.connection = "offline";
}
@@ -5884,6 +5985,10 @@ export function initEvents() {
});
api.onEvent("soulseek:upload", (u) => {
// A FIRST event for an id is someone new taking a file — announce it
// (UploadToast). Grabbing an album lands a burst of ids from one
// user; announceUpload folds those into one counting pill.
if (!(u.id in network.uploads)) announceUpload(u.username, u.name);
network.uploads[u.id] = u;
if (TERMINAL_TRANSFER_STATES.has(u.state)) {
trimTransferHistory(network.uploads, UPLOAD_HISTORY_MAX);
@@ -5894,6 +5999,53 @@ export function initEvents() {
network.respondedCount += 1;
});
// ---- the Soulseek hub's seats: rooms, chat, buddies ----
api.onEvent("soulseek:room-list", (rooms) => {
slsk.rooms = rooms;
});
api.onEvent("soulseek:room-joined", (p) => {
const prev = slsk.joined[p.room];
slsk.joined[p.room] = { users: p.users, messages: prev?.messages ?? [] };
slsk.activeRoom ??= p.room;
});
api.onEvent("soulseek:room-left", (p) => {
delete slsk.joined[p.room];
if (slsk.activeRoom === p.room) {
slsk.activeRoom = Object.keys(slsk.joined)[0] ?? null;
}
});
api.onEvent("soulseek:room-user", (p) => {
const room = slsk.joined[p.room];
if (!room) return;
if (p.joined) {
if (!room.users.some((u) => u.username === p.user.username)) {
room.users.push(p.user);
}
} else {
room.users = room.users.filter((u) => u.username !== p.username);
}
});
api.onEvent("soulseek:room-message", (p) => {
const room = slsk.joined[p.room];
if (!room) return;
room.messages.push({ username: p.username, message: p.message, ts: p.ts });
trimFeed(room.messages, SLSK_FEED_MAX);
});
api.onEvent("soulseek:private-message", (p) => {
const chat = (slsk.chats[p.username] ??= { messages: [], unread: 0 });
chat.messages.push({
me: false,
text: p.message,
ts: (p.timestamp || 0) * 1000 || Date.now(),
});
trimFeed(chat.messages, SLSK_FEED_MAX);
if (slsk.activeChat !== p.username) chat.unread += 1;
});
api.onEvent("soulseek:user-state", (p) => {
const s = (slsk.buddyState[p.username] ??= {});
Object.assign(s, p);
});
api.onEvent("soulseek:download", (d) => {
// Preserve last known progress on terminal states that carry none.
const prev = downloads.items[d.id];
@@ -7159,6 +7311,7 @@ if (import.meta.env.DEV && typeof window !== "undefined") {
artBroken, markArtBroken,
coverUpgrades, resolveCovers, playerArtSource, libraryHasAlbum,
catalogGo, goToView, openEntry, navBack, navForward, navToCrumb, nav,
slsk,
};
}
@@ -7501,5 +7654,6 @@ if (import.meta.env.DEV) {
catalogGo, refreshLibrary, refreshRecents,
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
playlistStore, refreshPlaylists, goToView, peekNextIndices,
slsk, search, runSearch, uploadToast, announceUpload,
};
}