From 63a0357651d730f6ab611161ccc6250087a729bd Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:13:53 -0500 Subject: [PATCH] the soulseek hub: the whole Qt client as one area, wired to the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/soulseek-client/src/conn.rs | 101 ++ crates/soulseek-client/src/lib.rs | 136 ++- crates/soulseek-proto/src/peer.rs | 61 ++ crates/soulseek-proto/src/server.rs | 370 +++++++ crates/soulseek-proto/src/types.rs | 10 + src-tauri/src/commands.rs | 292 ++++++ src-tauri/src/config.rs | 4 + src-tauri/src/lib.rs | 9 + src/App.svelte | 20 +- src/components/GlassBar.svelte | 47 +- src/components/Sidebar.svelte | 33 +- src/components/UploadToast.svelte | 121 +++ src/lib/api.js | 12 + src/lib/state.svelte.js | 154 +++ src/views/Downloads.svelte | 343 ------ src/views/Search.svelte | 408 -------- src/views/Soulseek.svelte | 1495 +++++++++++++++++++++++++++ 17 files changed, 2843 insertions(+), 773 deletions(-) create mode 100644 src/components/UploadToast.svelte delete mode 100644 src/views/Downloads.svelte delete mode 100644 src/views/Search.svelte create mode 100644 src/views/Soulseek.svelte diff --git a/crates/soulseek-client/src/conn.rs b/crates/soulseek-client/src/conn.rs index 718da1c..2979d80 100644 --- a/crates/soulseek-client/src/conn.rs +++ b/crates/soulseek-client/src/conn.rs @@ -195,6 +195,88 @@ fn handle_server_frame(inner: &Arc, mut body: Bytes) { crate::responder::respond_to_search(inner.clone(), username, token, query); } } + server::ServerEvent::RoomList { rooms } => { + let _ = inner.event_tx.send(Event::RoomList { rooms }); + } + server::ServerEvent::RoomJoined { room, users } => { + let _ = inner.event_tx.send(Event::RoomJoined { room, users }); + } + server::ServerEvent::RoomLeft { room } => { + let _ = inner.event_tx.send(Event::RoomLeft { room }); + } + server::ServerEvent::RoomUserJoined { room, user } => { + let _ = inner.event_tx.send(Event::RoomUserJoined { room, user }); + } + server::ServerEvent::RoomUserLeft { room, username } => { + let _ = inner.event_tx.send(Event::RoomUserLeft { room, username }); + } + server::ServerEvent::RoomChat { + room, + username, + message, + } => { + let _ = inner.event_tx.send(Event::RoomMessage { + room, + username, + message, + }); + } + server::ServerEvent::PrivateMessage { + id, + timestamp, + username, + message, + } => { + // Ack immediately or the server redelivers it on every login. + let _ = inner.send_server(server::message_acked(id)); + let _ = inner.event_tx.send(Event::PrivateMessage { + id, + timestamp, + username, + message, + }); + } + server::ServerEvent::UserWatched { + username, + exists, + status, + avg_speed, + file_count, + dir_count, + } => { + let _ = inner.event_tx.send(Event::UserWatched { + username, + exists, + status, + avg_speed, + file_count, + dir_count, + }); + } + server::ServerEvent::UserStatus { + username, + status, + privileged, + } => { + let _ = inner.event_tx.send(Event::UserStatus { + username, + status, + privileged, + }); + } + server::ServerEvent::UserStats { + username, + avg_speed, + file_count, + dir_count, + } => { + let _ = inner.event_tx.send(Event::UserStats { + username, + avg_speed, + file_count, + dir_count, + }); + } server::ServerEvent::CannotConnect { .. } | server::ServerEvent::Unhandled { .. } => {} } } @@ -507,6 +589,25 @@ fn route_peer_event(inner: &Arc, username: &str, ev: peer::PeerEvent) { crate::responder::respond_browse(inner, h); } } + BrowseResponse { dirs, .. } => { + let waiters = inner + .pending_browses + .lock() + .unwrap() + .remove(username) + .unwrap_or_default(); + match waiters.len() { + 0 => log::debug!("unsolicited browse response from {username}"), + 1 => { + let _ = waiters.into_iter().next().unwrap().send(dirs); + } + _ => { + for tx in waiters { + let _ = tx.send(dirs.clone()); + } + } + } + } UserInfoRequest => { if let Some(h) = inner.peers.lock().unwrap().get(username) { crate::responder::respond_user_info(inner, h); diff --git a/crates/soulseek-client/src/lib.rs b/crates/soulseek-client/src/lib.rs index c02768f..30b7ffd 100644 --- a/crates/soulseek-client/src/lib.rs +++ b/crates/soulseek-client/src/lib.rs @@ -29,7 +29,13 @@ use tokio::net::TcpStream; use tokio::sync::{mpsc, oneshot}; use tokio_util::sync::CancellationToken; -use soulseek_proto::{server, types::SearchResponse}; +use soulseek_proto::{ + peer, server, + types::{Directory, SearchResponse}, +}; + +pub use soulseek_proto::server::RoomUser; +pub use soulseek_proto::types::Directory as BrowseDirectory; pub const DEFAULT_SERVER: (&str, u16) = ("server.slsknet.org", 2242); pub const DEFAULT_LISTEN_PORT: u16 = 50300; @@ -86,6 +92,56 @@ pub enum Event { username: String, files: usize, }, + // ---- rooms / chat / users (the Soulseek hub's seats) ---- + RoomList { + rooms: Vec<(String, u32)>, + }, + RoomJoined { + room: String, + users: Vec, + }, + RoomLeft { + room: String, + }, + RoomUserJoined { + room: String, + user: RoomUser, + }, + RoomUserLeft { + room: String, + username: String, + }, + RoomMessage { + room: String, + username: String, + message: String, + }, + /// Already acked to the server by the time it is emitted. + PrivateMessage { + id: u32, + timestamp: u32, + username: String, + message: String, + }, + UserWatched { + username: String, + exists: bool, + status: u32, + avg_speed: u32, + file_count: u32, + dir_count: u32, + }, + UserStatus { + username: String, + status: u32, + privileged: bool, + }, + UserStats { + username: String, + avg_speed: u32, + file_count: u32, + dir_count: u32, + }, } #[derive(Debug, thiserror::Error)] @@ -148,6 +204,8 @@ pub(crate) struct Inner { pub expected_transfers: Mutex>>, /// Live peer message connections. pub peers: Mutex>, + /// Browse requests awaiting a peer's share listing, by username. + pub pending_browses: Mutex>>>>, /// TransferResponse waits: (username, our token). pub transfer_waits: Mutex>>, /// TransferRequest(upload) waits: (username, remote filename). @@ -258,6 +316,7 @@ impl Client { pending_pierce: Mutex::new(HashMap::new()), expected_transfers: Mutex::new(HashMap::new()), peers: Mutex::new(HashMap::new()), + pending_browses: Mutex::new(HashMap::new()), transfer_waits: Mutex::new(HashMap::new()), upload_offers: Mutex::new(HashMap::new()), downloads_by_file: Mutex::new(HashMap::new()), @@ -415,6 +474,81 @@ impl Client { } } + // ---- rooms / chat / users ---- + + /// Join (or create) a public room. The roster arrives as + /// [`Event::RoomJoined`]; traffic as [`Event::RoomMessage`]. + pub fn join_room(&self, room: &str) -> Result<()> { + self.inner.send_server(server::join_room(room)) + } + + pub fn leave_room(&self, room: &str) -> Result<()> { + self.inner.send_server(server::leave_room(room)) + } + + pub fn say_room(&self, room: &str, message: &str) -> Result<()> { + self.inner.send_server(server::say_chatroom(room, message)) + } + + /// Send a private message. Delivery/acks are the server's business; + /// incoming replies arrive as [`Event::PrivateMessage`]. + pub fn message_user(&self, username: &str, message: &str) -> Result<()> { + self.inner.send_server(server::message_user(username, message)) + } + + /// Watch a user: stats now, status pushes until unwatched. + pub fn watch_user(&self, username: &str) -> Result<()> { + self.inner.send_server(server::watch_user(username)) + } + + pub fn unwatch_user(&self, username: &str) -> Result<()> { + self.inner.send_server(server::unwatch_user(username)) + } + + pub fn get_user_stats(&self, username: &str) -> Result<()> { + self.inner.send_server(server::get_user_stats(username)) + } + + /// Ask for the public room list (also pushed unprompted after login). + pub fn request_room_list(&self) -> Result<()> { + self.inner.send_server(server::room_list()) + } + + /// Fetch a user's full shared listing over a peer connection. Slow for + /// big shares — the reply is one large compressed frame — so this is + /// spawn-shaped: the returned future owns everything it needs and the + /// caller can drop whatever lock guarded the `Client` before awaiting. + pub fn browse( + &self, + username: &str, + ) -> impl std::future::Future>> + Send + 'static { + let inner = self.inner.clone(); + let username = username.to_string(); + async move { + let handle = conn::get_or_connect_peer(&inner, &username).await?; + let (tx, rx) = oneshot::channel(); + inner + .pending_browses + .lock() + .unwrap() + .entry(username.clone()) + .or_default() + .push(tx); + handle + .write_tx + .send(peer::browse_request()) + .map_err(|_| ClientError::PeerUnreachable(username.clone()))?; + + match tokio::time::timeout(std::time::Duration::from_secs(45), rx).await { + Ok(Ok(dirs)) => Ok(dirs), + _ => { + inner.pending_browses.lock().unwrap().remove(&username); + Err(ClientError::Timeout) + } + } + } + } + pub fn listen_port(&self) -> u16 { self.inner.listen_port } diff --git a/crates/soulseek-proto/src/peer.rs b/crates/soulseek-proto/src/peer.rs index cc0544b..63074ab 100644 --- a/crates/soulseek-proto/src/peer.rs +++ b/crates/soulseek-proto/src/peer.rs @@ -120,6 +120,11 @@ pub fn transfer_response_rejected(token: u32, reason: &str) -> Vec { .finish_u32(code::TRANSFER_RESPONSE) } +/// Ask a peer for their full shared listing (answered with code 5). +pub fn browse_request() -> Vec { + Writer::new().finish_u32(code::BROWSE_REQUEST) +} + /// Legacy enqueue message; some clients expect it before TransferRequest. pub fn queue_download(filename: &str) -> Vec { Writer::new() @@ -210,6 +215,12 @@ pub fn user_info_response( #[derive(Debug, Clone)] pub enum PeerEvent { SearchResponse(SearchResponse), + /// A peer's answer to our browse_request: their shared tree. + BrowseResponse { + dirs: Vec, + /// Count only — locked directories need a password we don't speak. + locked_dirs: u32, + }, /// Peer is ready to send us a file (direction=upload) or wants one /// from us (direction=download → an upload request we service). TransferRequest { @@ -260,6 +271,9 @@ pub fn decode(code: u32, payload: Bytes) -> Result { if code == code::SEARCH_RESPONSE { return Ok(PeerEvent::SearchResponse(decode_search_response(payload)?)); } + if code == code::BROWSE_RESPONSE { + return decode_browse_response(payload); + } let mut r = Reader::new(payload); Ok(match code { code::TRANSFER_REQUEST => { @@ -322,6 +336,24 @@ pub fn decode(code: u32, payload: Bytes) -> Result { }) } +/// Payload is zlib-compressed: directory count then directories — the exact +/// shape `browse_response` writes. The trailer (unknown u32 + locked +/// directory section) is read for its count and otherwise ignored. +fn decode_browse_response(payload: Bytes) -> Result { + let mut r = Reader::new(payload); + r.decompress()?; + let count = r.u32()?.min(100_000); + let mut dirs = Vec::with_capacity(count.min(4096) as usize); + for _ in 0..count { + dirs.push(Directory::read(&mut r)?); + } + if r.has_more() { + let _unknown = r.u32()?; + } + let locked_dirs = if r.has_more() { r.u32().unwrap_or(0) } else { 0 }; + Ok(PeerEvent::BrowseResponse { dirs, locked_dirs }) +} + /// Payload is zlib-compressed: username, token, files, slot/speed/queue, /// optional unknown u32, optional locked file list. fn decode_search_response(payload: Bytes) -> Result { @@ -398,6 +430,35 @@ mod tests { } } + #[test] + fn browse_roundtrip() { + // Our own responder encoding must decode with our own reader — the + // two sides of the same wire shape. + let dirs = vec![crate::types::Directory { + name: "Music\\Panda Bear\\A ? of WHEN [FLAC]".into(), + files: vec![crate::types::File { + filename: "01 Never givin' in.flac".into(), + size: 38_900_000, + extension: "flac".into(), + attributes: vec![(crate::types::ATTR_DURATION, 330)], + }], + }]; + let msg = browse_response(&dirs); + // Strip length + code, decode the compressed payload. + let ev = decode(code::BROWSE_RESPONSE, Bytes::copy_from_slice(&msg[8..])).unwrap(); + match ev { + PeerEvent::BrowseResponse { dirs, locked_dirs } => { + assert_eq!(dirs.len(), 1); + assert_eq!(dirs[0].name, "Music\\Panda Bear\\A ? of WHEN [FLAC]"); + assert_eq!(dirs[0].files.len(), 1); + assert_eq!(dirs[0].files[0].size, 38_900_000); + assert_eq!(dirs[0].files[0].duration_secs(), Some(330)); + assert_eq!(locked_dirs, 0); + } + other => panic!("wrong event: {other:?}"), + } + } + #[test] fn search_response_decode() { // Build the uncompressed body by hand. diff --git a/crates/soulseek-proto/src/server.rs b/crates/soulseek-proto/src/server.rs index 887a05e..e52ffea 100644 --- a/crates/soulseek-proto/src/server.rs +++ b/crates/soulseek-proto/src/server.rs @@ -17,10 +17,21 @@ pub mod code { pub const SET_LISTEN_PORT: u32 = 2; pub const GET_PEER_ADDRESS: u32 = 3; pub const WATCH_USER: u32 = 5; + pub const UNWATCH_USER: u32 = 6; + pub const GET_USER_STATUS: u32 = 7; + pub const SAY_CHATROOM: u32 = 13; + pub const JOIN_ROOM: u32 = 14; + pub const LEAVE_ROOM: u32 = 15; + pub const USER_JOINED_ROOM: u32 = 16; + pub const USER_LEFT_ROOM: u32 = 17; pub const CONNECT_TO_PEER: u32 = 18; + pub const MESSAGE_USER: u32 = 22; + pub const MESSAGE_ACKED: u32 = 23; pub const FILE_SEARCH: u32 = 26; pub const PING: u32 = 32; pub const SHARED_FOLDERS_AND_FILES: u32 = 35; + pub const GET_USER_STATS: u32 = 36; + pub const ROOM_LIST: u32 = 64; pub const HAVE_NO_PARENTS: u32 = 71; pub const PARENT_MIN_SPEED: u32 = 83; pub const PARENT_SPEED_RATIO: u32 = 84; @@ -101,8 +112,76 @@ pub fn have_no_parents(value: bool) -> Vec { Writer::new().bool(value).finish_u32(code::HAVE_NO_PARENTS) } +// ---- rooms / chat / users (outgoing) ---- + +/// Watch a user: the server answers with their stats and then pushes status +/// changes until unwatched. The buddy list rides on this. +pub fn watch_user(username: &str) -> Vec { + Writer::new().string(username).finish_u32(code::WATCH_USER) +} + +pub fn unwatch_user(username: &str) -> Vec { + Writer::new().string(username).finish_u32(code::UNWATCH_USER) +} + +pub fn get_user_status(username: &str) -> Vec { + Writer::new() + .string(username) + .finish_u32(code::GET_USER_STATUS) +} + +pub fn get_user_stats(username: &str) -> Vec { + Writer::new() + .string(username) + .finish_u32(code::GET_USER_STATS) +} + +pub fn say_chatroom(room: &str, message: &str) -> Vec { + Writer::new() + .string(room) + .string(message) + .finish_u32(code::SAY_CHATROOM) +} + +/// The trailing u32 is the private flag; 0 joins/creates a public room. +pub fn join_room(room: &str) -> Vec { + Writer::new().string(room).u32(0).finish_u32(code::JOIN_ROOM) +} + +pub fn leave_room(room: &str) -> Vec { + Writer::new().string(room).finish_u32(code::LEAVE_ROOM) +} + +pub fn message_user(username: &str, message: &str) -> Vec { + Writer::new() + .string(username) + .string(message) + .finish_u32(code::MESSAGE_USER) +} + +/// Acknowledge a private message so the server stops redelivering it. +pub fn message_acked(id: u32) -> Vec { + Writer::new().u32(id).finish_u32(code::MESSAGE_ACKED) +} + +/// Ask for the room list (the server also pushes one after login). +pub fn room_list() -> Vec { + Writer::new().finish_u32(code::ROOM_LIST) +} + // ---- incoming ---- +/// A user as a room roster reports them (join replies and join notices). +#[derive(Debug, Clone, Default)] +pub struct RoomUser { + pub username: String, + /// 0 offline · 1 away · 2 online. + pub status: u32, + pub avg_speed: u32, + pub file_count: u32, + pub dir_count: u32, +} + #[derive(Debug, Clone)] pub enum ServerEvent { LoginOk { @@ -145,6 +224,63 @@ pub enum ServerEvent { /// bytes are a distributed frame starting with its code byte — decode /// with `dist::decode`. Embedded(Bytes), + /// Public rooms and their user counts (pushed after login, and on + /// request). Private-room sections in the payload are ignored. + RoomList { + rooms: Vec<(String, u32)>, + }, + /// We are in the room now; the full roster rides along. + RoomJoined { + room: String, + users: Vec, + }, + RoomLeft { + room: String, + }, + RoomUserJoined { + room: String, + user: RoomUser, + }, + RoomUserLeft { + room: String, + username: String, + }, + /// A line said in a room we are in. + RoomChat { + room: String, + username: String, + message: String, + }, + /// A private message. Ack with `message_acked(id)` or the server + /// redelivers it on every login. + PrivateMessage { + id: u32, + timestamp: u32, + username: String, + message: String, + }, + /// Answer to watch_user. + UserWatched { + username: String, + exists: bool, + status: u32, + avg_speed: u32, + file_count: u32, + dir_count: u32, + }, + /// Pushed for watched users, and the answer to get_user_status. + UserStatus { + username: String, + status: u32, + privileged: bool, + }, + /// Answer to get_user_stats. + UserStats { + username: String, + avg_speed: u32, + file_count: u32, + dir_count: u32, + }, Unhandled { code: u32, }, @@ -224,6 +360,143 @@ pub fn decode(code: u32, payload: Bytes) -> Result { } } code::EMBEDDED_MESSAGE => ServerEvent::Embedded(r.rest()), + code::SAY_CHATROOM => ServerEvent::RoomChat { + room: r.string()?, + username: r.string()?, + message: r.string()?, + }, + code::JOIN_ROOM => { + // room; N names; N statuses; N stats (speed u32, uploads u64, + // files u32, dirs u32); then slots-free and country sections we + // don't need. Sections beyond the names are tolerated missing — + // rosters from older servers stop early. + let room = r.string()?; + let count = r.u32()?.min(10_000) as usize; + let mut users: Vec = Vec::with_capacity(count.min(4096)); + for _ in 0..count { + users.push(RoomUser { + username: r.string()?, + ..Default::default() + }); + } + // Each section carries its own count; consume EVERY entry it + // declares (misaligned reads poison everything after), assign + // only the ones with a roster slot. + if r.has_more() { + let n = r.u32()?.min(10_000) as usize; + for i in 0..n { + let status = r.u32()?; + if let Some(user) = users.get_mut(i) { + user.status = status; + } + } + } + if r.has_more() { + let n = r.u32()?.min(10_000) as usize; + for i in 0..n { + let avg_speed = r.u32()?; + let _upload_count = r.u64()?; + let file_count = r.u32()?; + let dir_count = r.u32()?; + if let Some(user) = users.get_mut(i) { + user.avg_speed = avg_speed; + user.file_count = file_count; + user.dir_count = dir_count; + } + } + } + ServerEvent::RoomJoined { room, users } + } + code::LEAVE_ROOM => ServerEvent::RoomLeft { room: r.string()? }, + code::USER_JOINED_ROOM => { + let room = r.string()?; + let username = r.string()?; + let status = r.u32().unwrap_or(2); + let avg_speed = r.u32().unwrap_or(0); + let _upload_count = r.u64().unwrap_or(0); + let file_count = r.u32().unwrap_or(0); + let dir_count = r.u32().unwrap_or(0); + ServerEvent::RoomUserJoined { + room, + user: RoomUser { + username, + status, + avg_speed, + file_count, + dir_count, + }, + } + } + code::USER_LEFT_ROOM => ServerEvent::RoomUserLeft { + room: r.string()?, + username: r.string()?, + }, + code::MESSAGE_USER => ServerEvent::PrivateMessage { + id: r.u32()?, + timestamp: r.u32()?, + username: r.string()?, + message: r.string()?, + }, + code::WATCH_USER => { + let username = r.string()?; + let exists = r.bool()?; + let (mut status, mut avg_speed, mut file_count, mut dir_count) = (0, 0, 0, 0); + if exists && r.has_more() { + status = r.u32()?; + avg_speed = r.u32()?; + let _upload_count = r.u64()?; + file_count = r.u32()?; + dir_count = r.u32()?; + } + ServerEvent::UserWatched { + username, + exists, + status, + avg_speed, + file_count, + dir_count, + } + } + code::GET_USER_STATUS => ServerEvent::UserStatus { + username: r.string()?, + status: r.u32()?, + privileged: r.bool().unwrap_or(false), + }, + code::GET_USER_STATS => { + let username = r.string()?; + let avg_speed = r.u32()?; + let _upload_count = r.u64()?; + ServerEvent::UserStats { + username, + avg_speed, + file_count: r.u32()?, + dir_count: r.u32()?, + } + } + code::ROOM_LIST => { + // Public names + counts; the private-room sections that follow + // are deliberately not parsed. + let count = r.u32()?.min(50_000) as usize; + let mut names = Vec::with_capacity(count.min(8192)); + for _ in 0..count { + names.push(r.string()?); + } + let ccount = if r.has_more() { + r.u32()?.min(50_000) as usize + } else { + 0 + }; + let mut counts = Vec::with_capacity(ccount.min(8192)); + for _ in 0..ccount { + counts.push(r.u32()?); + } + let rooms = names + .into_iter() + .enumerate() + .map(|(i, name)| (name, counts.get(i).copied().unwrap_or(0))) + .collect(); + ServerEvent::RoomList { rooms } + } code::EXCLUDED_SEARCH_PHRASES => { let count = r.u32()?.min(10_000); let mut phrases = Vec::with_capacity(count as usize); @@ -257,6 +530,103 @@ mod tests { assert_eq!(r.u32().unwrap(), MINOR_VERSION); } + #[test] + fn decode_room_traffic() { + // A said line. + let payload = Writer::new() + .string("indie") + .string("nikac") + .string("browse me") + .finish_u32(13); + match decode(13, Bytes::copy_from_slice(&payload[8..])).unwrap() { + ServerEvent::RoomChat { + room, + username, + message, + } => { + assert_eq!((room.as_str(), username.as_str()), ("indie", "nikac")); + assert_eq!(message, "browse me"); + } + other => panic!("wrong event: {other:?}"), + } + + // A join reply: 2 users, statuses and stats sections. + let payload = Writer::new() + .string("indie") + .u32(2) + .string("alice") + .string("bob") + .u32(2) + .u32(2) + .u32(1) + .u32(2) + .u32(500_000) + .u64(3) + .u32(1200) + .u32(80) + .u32(90_000) + .u64(0) + .u32(64) + .u32(7) + .finish_u32(14); + match decode(14, Bytes::copy_from_slice(&payload[8..])).unwrap() { + ServerEvent::RoomJoined { room, users } => { + assert_eq!(room, "indie"); + assert_eq!(users.len(), 2); + assert_eq!(users[0].username, "alice"); + assert_eq!(users[0].status, 2); + assert_eq!(users[0].avg_speed, 500_000); + assert_eq!(users[0].file_count, 1200); + assert_eq!(users[1].username, "bob"); + assert_eq!(users[1].status, 1); + assert_eq!(users[1].dir_count, 7); + } + other => panic!("wrong event: {other:?}"), + } + + // A private message. + let payload = Writer::new() + .u32(77) + .u32(1_788_000_000) + .string("seaside58") + .string("queued you up") + .bool(true) + .finish_u32(22); + match decode(22, Bytes::copy_from_slice(&payload[8..])).unwrap() { + ServerEvent::PrivateMessage { + id, + username, + message, + .. + } => { + assert_eq!(id, 77); + assert_eq!(username, "seaside58"); + assert_eq!(message, "queued you up"); + } + other => panic!("wrong event: {other:?}"), + } + + // A room list with a private tail we ignore. + let payload = Writer::new() + .u32(2) + .string("indie") + .string("electronic") + .u32(2) + .u32(214) + .u32(642) + .u32(0) // owned-private names, ignored + .u32(0) // owned-private counts, ignored + .finish_u32(64); + match decode(64, Bytes::copy_from_slice(&payload[8..])).unwrap() { + ServerEvent::RoomList { rooms } => { + assert_eq!(rooms.len(), 2); + assert_eq!(rooms[0], ("indie".to_string(), 214)); + assert_eq!(rooms[1], ("electronic".to_string(), 642)); + } + other => panic!("wrong event: {other:?}"), + } + } + #[test] fn decode_connect_to_peer() { let payload = Writer::new() diff --git a/crates/soulseek-proto/src/types.rs b/crates/soulseek-proto/src/types.rs index 0439861..39e431d 100644 --- a/crates/soulseek-proto/src/types.rs +++ b/crates/soulseek-proto/src/types.rs @@ -104,6 +104,16 @@ pub struct Directory { } impl Directory { + pub(crate) fn read(r: &mut Reader) -> Result { + let name = r.string()?; + let count = r.u32()?.min(100_000); + let mut files = Vec::with_capacity(count.min(4096) as usize); + for _ in 0..count { + files.push(File::read(r)?); + } + Ok(Directory { name, files }) + } + pub fn write(&self, mut w: Writer) -> Writer { w = w.string(&self.name).u32(self.files.len() as u32); for f in &self.files { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index df13a50..f165310 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -498,6 +498,11 @@ pub async fn connect_and_login( serde_json::json!({ "folders": folders, "files": files }), ); + // Watch the buddy list so status/stats stream in for the Users tab. + for buddy in &config::load(&app).buddies { + let _ = client.watch_user(buddy); + } + *state.client.lock().await = Some(client); *state.connected_as.lock().await = Some(username.clone()); @@ -864,6 +869,116 @@ pub async fn connect_and_login( serde_json::json!({ "username": username, "files": files }), ); } + // ---- the Soulseek hub's seats: rooms, chat, users ---- + Event::RoomList { rooms } => { + let rooms: Vec<_> = rooms + .into_iter() + .map(|(name, users)| serde_json::json!({ "name": name, "users": users })) + .collect(); + let _ = event_app.emit("soulseek:room-list", rooms); + } + Event::RoomJoined { room, users } => { + let _ = event_app.emit( + "soulseek:room-joined", + serde_json::json!({ "room": room, "users": room_users_json(&users) }), + ); + } + Event::RoomLeft { room } => { + let _ = event_app.emit("soulseek:room-left", serde_json::json!({ "room": room })); + } + Event::RoomUserJoined { room, user } => { + let _ = event_app.emit( + "soulseek:room-user", + serde_json::json!({ + "room": room, + "joined": true, + "user": room_user_json(&user), + }), + ); + } + Event::RoomUserLeft { room, username } => { + let _ = event_app.emit( + "soulseek:room-user", + serde_json::json!({ "room": room, "joined": false, "username": username }), + ); + } + Event::RoomMessage { + room, + username, + message, + } => { + let _ = event_app.emit( + "soulseek:room-message", + serde_json::json!({ + "room": room, + "username": username, + "message": message, + "ts": now_ms(), + }), + ); + } + Event::PrivateMessage { + id, + timestamp, + username, + message, + } => { + let _ = event_app.emit( + "soulseek:private-message", + serde_json::json!({ + "id": id, + "timestamp": timestamp, + "username": username, + "message": message, + }), + ); + } + Event::UserWatched { + username, + exists, + status, + avg_speed, + file_count, + dir_count, + } => { + let _ = event_app.emit( + "soulseek:user-state", + serde_json::json!({ + "kind": "watched", + "username": username, + "exists": exists, + "status": status, + "avg_speed": avg_speed, + "file_count": file_count, + "dir_count": dir_count, + }), + ); + } + Event::UserStatus { + username, status, .. + } => { + let _ = event_app.emit( + "soulseek:user-state", + serde_json::json!({ "kind": "status", "username": username, "status": status }), + ); + } + Event::UserStats { + username, + avg_speed, + file_count, + dir_count, + } => { + let _ = event_app.emit( + "soulseek:user-state", + serde_json::json!({ + "kind": "stats", + "username": username, + "avg_speed": avg_speed, + "file_count": file_count, + "dir_count": dir_count, + }), + ); + } _ => {} } } @@ -872,6 +987,183 @@ pub async fn connect_and_login( Ok(greeting) } +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +fn room_user_json(u: &soulseek_client::RoomUser) -> serde_json::Value { + serde_json::json!({ + "username": u.username, + "status": u.status, + "avg_speed": u.avg_speed, + "file_count": u.file_count, + "dir_count": u.dir_count, + }) +} + +fn room_users_json(users: &[soulseek_client::RoomUser]) -> Vec { + users.iter().map(room_user_json).collect() +} + +// ---- the Soulseek hub: rooms / chat / users / browse commands ---- + +/// Briefly lock the client, run `f`, release. Every hub command is a small +/// synchronous send; only browse runs long, and it detaches first. +async fn with_client( + state: &State<'_, AppState>, + f: impl FnOnce(&soulseek_client::Client) -> soulseek_client::Result, +) -> Result { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected to Soulseek")?; + f(client).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn slsk_join_room(state: State<'_, AppState>, room: String) -> Result<(), String> { + with_client(&state, |c| c.join_room(&room)).await +} + +#[tauri::command] +pub async fn slsk_leave_room(state: State<'_, AppState>, room: String) -> Result<(), String> { + with_client(&state, |c| c.leave_room(&room)).await +} + +#[tauri::command] +pub async fn slsk_say_room( + state: State<'_, AppState>, + room: String, + message: String, +) -> Result<(), String> { + let message = message.trim().to_string(); + if message.is_empty() { + return Ok(()); + } + with_client(&state, |c| c.say_room(&room, &message)).await +} + +#[tauri::command] +pub async fn slsk_message_user( + state: State<'_, AppState>, + username: String, + message: String, +) -> Result<(), String> { + let message = message.trim().to_string(); + if message.is_empty() { + return Ok(()); + } + with_client(&state, |c| c.message_user(&username, &message)).await +} + +#[tauri::command] +pub async fn slsk_room_list(state: State<'_, AppState>) -> Result<(), String> { + with_client(&state, |c| c.request_room_list()).await +} + +#[derive(serde::Serialize)] +pub struct BrowseFileDto { + pub name: String, + /// Full remote path (folder + name), ready for a download request. + pub path: String, + pub size: u64, + pub ext: String, + pub bitrate: Option, + pub duration: Option, +} + +#[derive(serde::Serialize)] +pub struct BrowseDirDto { + pub name: String, + pub files: Vec, +} + +/// Walk a user's shares. Long-running: the client lock is held only long +/// enough to start the request. +#[tauri::command] +pub async fn slsk_browse_user( + state: State<'_, AppState>, + username: String, +) -> Result, String> { + let fut = { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected to Soulseek")?; + client.browse(&username) + }; + let dirs = fut.await.map_err(|e| e.to_string())?; + Ok(dirs + .into_iter() + .map(|d| { + let dir_name = d.name.clone(); + BrowseDirDto { + files: d + .files + .into_iter() + .map(|f| { + let bitrate = f.bitrate(); + let duration = f.duration_secs(); + let ext = if f.extension.is_empty() { + f.filename.rsplit('.').next().unwrap_or_default().to_string() + } else { + f.extension.clone() + }; + BrowseFileDto { + path: format!("{dir_name}\\{}", f.filename), + name: f.filename, + size: f.size, + ext, + bitrate, + duration, + } + }) + .collect(), + name: dir_name, + } + }) + .collect()) +} + +#[tauri::command] +pub fn buddy_list(app: tauri::AppHandle) -> Vec { + config::load(&app).buddies +} + +#[tauri::command] +pub async fn buddy_add( + app: tauri::AppHandle, + state: State<'_, AppState>, + username: String, +) -> Result, String> { + let username = username.trim().to_string(); + if username.is_empty() { + return Err("no username".into()); + } + let mut cfg = config::load(&app); + if !cfg.buddies.iter().any(|b| b == &username) { + cfg.buddies.push(username.clone()); + config::save(&app, &cfg); + } + // Watch immediately when connected; on later logins the connect path + // re-watches the whole list. + let _ = with_client(&state, |c| c.watch_user(&username)).await; + let _ = with_client(&state, |c| c.get_user_stats(&username)).await; + Ok(cfg.buddies) +} + +#[tauri::command] +pub async fn buddy_remove( + app: tauri::AppHandle, + state: State<'_, AppState>, + username: String, +) -> Result, String> { + let mut cfg = config::load(&app); + cfg.buddies.retain(|b| b != &username); + config::save(&app, &cfg); + let _ = with_client(&state, |c| c.unwatch_user(&username)).await; + Ok(cfg.buddies) +} + // ---- catalog browser (Deezer-backed) ---- const ARTIST_HERO_MIN_WIDTH: u32 = 1920; diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index c1a8c31..d64257c 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -144,6 +144,10 @@ pub struct AppConfig { /// base surfaces thinned in CSS so the desktop glows through. None → false. #[serde(default)] pub glass_mode: Option, + /// The Soulseek hub's buddy list — usernames watched on every login so + /// their status and stats stream in while connected. + #[serde(default)] + pub buddies: Vec, } /// Default longest edge for embedded covers when the user hasn't chosen. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9b2f5ab..3e26b20 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -106,6 +106,15 @@ pub fn run() { commands::followed_artists, commands::follow_artist, commands::unfollow_artist, + commands::slsk_join_room, + commands::slsk_leave_room, + commands::slsk_say_room, + commands::slsk_message_user, + commands::slsk_room_list, + commands::slsk_browse_user, + commands::buddy_list, + commands::buddy_add, + commands::buddy_remove, commands::home_artist_news, commands::home_warm, commands::mini_open, diff --git a/src/App.svelte b/src/App.svelte index 990a7af..f415891 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -12,9 +12,8 @@ import GlassBar from "./components/GlassBar.svelte"; import { previewUi } from "./lib/preview/previewSession.svelte.js"; import Home from "./views/Home.svelte"; - import Search from "./views/Search.svelte"; + import Soulseek from "./views/Soulseek.svelte"; import Library from "./views/Library.svelte"; - import Downloads from "./views/Downloads.svelte"; import Settings from "./views/Settings.svelte"; import Playlist from "./views/Playlist.svelte"; import Playlists from "./views/Playlists.svelte"; @@ -26,6 +25,7 @@ import FollowToast from "./components/FollowToast.svelte"; import ImportToast from "./components/ImportToast.svelte"; import ResolverToast from "./components/ResolverToast.svelte"; + import UploadToast from "./components/UploadToast.svelte"; import SetlistScanChip from "./components/SetlistScanChip.svelte"; import { ui, @@ -43,9 +43,14 @@ const views = { home: Home, - search: Search, + // The Soulseek hub absorbed the old Search and Downloads views — + // their ids stay routable so the download ring, "get on soulseek" + // hand-offs, history entries and restored sessions all land on the + // hub's right tab (the alias → tab sync lives in Soulseek.svelte). + soulseek: Soulseek, + search: Soulseek, + downloads: Soulseek, library: Library, - downloads: Downloads, settings: Settings, playlist: Playlist, playlists: Playlists, @@ -78,6 +83,12 @@ "catalogAlbum", "catalog", "catalogDiscography", + // The Soulseek hub is a "start BELOW the bar" page (it takes + // padding-top: --pane-pad itself); its two alias ids ride along so + // the bar never blinks when an old caller routes through them. + "soulseek", + "search", + "downloads", // The Library follows the discography's exception: its blurred // ambience pins behind the glass and must begin at the very top of // the pane, so the page carries its own top inset instead of taking @@ -162,6 +173,7 @@ + diff --git a/src/components/GlassBar.svelte b/src/components/GlassBar.svelte index e0a12b8..64ffa6e 100644 --- a/src/components/GlassBar.svelte +++ b/src/components/GlassBar.svelte @@ -24,7 +24,15 @@ navBack, navForward, navToCrumb, + ui, + app, + network, } from "../lib/state.svelte.js"; + + // The Soulseek hub carries Qt's connection status in the bar's right — + // "online as X · sharing N files" — the way the Qt titlebar did. + const HUB_VIEWS = new Set(["soulseek", "search", "downloads"]); + const showConn = $derived(HUB_VIEWS.has(ui.view)); import { canGoBack, canGoForward, trail } from "../lib/navigation.svelte.js"; import { markAppScroll, appScrollIdleFor } from "../lib/scrollbar.js"; import Icon from "./Icon.svelte"; @@ -329,10 +337,10 @@ {/if}
{#if crumbs.length} @@ -362,6 +370,18 @@ {/if} {@render children?.()} + {#if showConn} + + + {#if app.connection === "online"} + online as {app.username}{network.shareFiles > 0 + ? ` · sharing ${network.shareFiles} files` + : ""} + {:else} + offline + {/if} + + {/if} diff --git a/src/lib/api.js b/src/lib/api.js index ba016ea..cd55c16 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -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). diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 60a1917..474daed 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -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, }; } diff --git a/src/views/Downloads.svelte b/src/views/Downloads.svelte deleted file mode 100644 index 8f9176d..0000000 --- a/src/views/Downloads.svelte +++ /dev/null @@ -1,343 +0,0 @@ - - -
-

Downloads

- - {#if network.shareFiles > 0} - - {/if} - - {#if uploads.length > 0} -

Uploads

-
- {#each uploads as u (u.id)} -
-
-
{u.name}
-
- to {u.username} · - - {u.state === "transferring" && u.total > 0 - ? `${((u.sent / u.total) * 100).toFixed(0)}%` - : u.state} - -
- {#if u.state === "transferring"} -
-
-
- {/if} -
-
- {/each} -
- {/if} - - {#if items.length === 0} - {#if uploads.length === 0} -
-

No downloads yet

-

Grab something from Search — files land in your library folder.

-
- {/if} - {:else} - {#if active.length > 0} -

Active

-
- {#each active as d (d.id)} -
-
-
{d.name}
-
{d.username} · {stateLabel(d)}
-
-
-
-
- -
- {/each} -
- {/if} - - {#if finished.length > 0} -

Finished

-
- {#each finished as d (d.id)} -
-
-
{d.name}
-
- {d.username} · - - {stateLabel(d)} - -
-
- {#if d.state === "complete" && d.local_path} - - - [{ - title: d.name.replace(/\.[^.]+$/, ""), - path: d.local_path, - }]} - /> - {/if} -
- {/each} -
- {/if} - {/if} -
- - diff --git a/src/views/Search.svelte b/src/views/Search.svelte deleted file mode 100644 index db3a8c4..0000000 --- a/src/views/Search.svelte +++ /dev/null @@ -1,408 +0,0 @@ - - - - - diff --git a/src/views/Soulseek.svelte b/src/views/Soulseek.svelte new file mode 100644 index 0000000..086a48d --- /dev/null +++ b/src/views/Soulseek.svelte @@ -0,0 +1,1495 @@ + + +
+
+ + + + + + +
+ + + {#if tab === "transfers"} +
+ + + + {#if dlFinished.length} + + {/if} + + +
+ + {#if sub === "downloads"} + {#if dlItems.length === 0} +
+

No downloads yet

+

Grab something from Search — files land in your library folder.

+
+ {:else} +
+ UserFileProgressStatus + ETASpeedSizePlace +
+ {#each dlItems as d (d.id)} + {@const chip = stateChip(d)} +
+ {d.username} + {splitPath(d.name).file} + + + + {chip.label} + {fmtEta(d)} + {d.state === "transferring" ? fmtSpeed(d.speed_bps) : "—"} + {fmtSize(d.total)} + {d.state === "queued" && d.queue_place != null ? d.queue_place : "—"} + + {#if ["connecting", "requested", "queued", "transferring"].includes(d.state)} + + {:else if d.state === "complete" && d.local_path} + + + [{ title: d.name.replace(/\.[^.]+$/, ""), path: d.local_path }]} + /> + {/if} + +
+ {/each} + {/if} + {:else if uploads.length === 0} +
+

Nothing uploading

+

+ {network.shareFiles > 0 + ? `Sharing ${network.shareFiles} files — uploads appear here when someone takes one.` + : "Connect and share folders in Settings to seed the network."} +

+
+ {:else} +
+ UserFileProgressStatus + ETASpeedSizePlace +
+ {#each uploads as u (u.id)} +
+ {u.username} + {u.name} + + {u.state === "transferring" ? "uploading" : u.state} + —— + {fmtSize(u.total)}— + +
+ {/each} + {/if} + +
+ ↓ {fmtSpeed(downSpeed)} down + ↑ {upActive} uploading + + {#if network.shareFiles > 0} + sharing {network.shareFiles} files{network.respondedCount > 0 ? ` · answered ${network.respondedCount} searches` : ""} + {/if} +
+ + + {:else if tab === "search"} +
+
+ + +
+ + + + +
+ + {#if search.token == null} +
+

Search the Soulseek network

+

+ {app.connection === "online" + ? `Connected as ${app.username} — search for an artist or album.` + : "You'll be asked to log in on your first search."} +

+
+ {:else} +
+ + {#if search.searching}{/if} + {search.query} + {filtered.length} + +
+
+ User / folder / fileSpeedQueue + QualityLengthSize +
+ {#each byUser as [username, ugroups] (username)} + {@const best = ugroups[0]} +
+ + {username} + {#if best.hasFreeSlot} + open slot + {:else} + queue {best.queue} + {/if} + + {fmtSpeed(best.speed)} + {best.hasFreeSlot ? "—" : best.queue} + +
+ {#each ugroups as g (g.key)} + + {#if expanded[g.key]} + {#each filesForGroup(g.key, g.fileCount) as f (f.path)} +
+ + {(f.name.split(".").pop() ?? "").toUpperCase()} + {f.name} + + + + + + + {f.bitrate ? `${f.bitrate}` : ""} + {fmtDuration(f.duration)} + {fmtSize(f.size)} +
+ {/each} + {/if} + {/each} + {/each} + {/if} + + + {:else if tab === "browse"} +
+
+ + +
+ {#if browseTotals} + {browseDirs.length} folders · {browseTotals.files} files · {fmtSize(browseTotals.bytes)} shared + {/if} +
+ {#if browseBusy} +
+

Reading {browseName.trim()}'s shares…

+

Big collections arrive as one large listing — give it a moment.

+
+ {:else if browseErr} +
+

Couldn't browse {browseName.trim()}

+

{browseErr}

+
+ {:else if browseDirs} +
+
+ {#each browseDirs as d, i (d.name)} + + {/each} +
+
+ {#if browseDir} +
{browseDir.name} — {browseDir.files.length} file{browseDir.files.length === 1 ? "" : "s"}
+ {#each browseDir.files as f (f.path)} +
+ + {f.ext.toUpperCase()} + {f.name} + + + + + + {f.bitrate ?? ""} + {fmtDuration(f.duration)} + {fmtSize(f.size)} +
+ {/each} + {/if} +
+
+ {:else} +
+

Browse a user's shares

+

Type a username — their whole folder tree opens with play and download on every file. Room rosters and buddies have one-click Browse too.

+
+ {/if} + + + {:else if tab === "rooms"} +
+
+
+ {#if Object.keys(slsk.joined).length} +
Joined
+ {#each Object.keys(slsk.joined) as name (name)} + + {/each} + {/if} +
Popular rooms
+ {#if roomList.length === 0} +
+ {app.connection === "online" + ? "The room list is on its way from the server…" + : "Connect to see the network's rooms."} +
+ {:else} + {#each roomList as r (r.name)} + + {/each} + {/if} +
+
+ {#if activeRoom} +
+ {#each activeRoom.messages as m (m.ts + m.username + m.message)} +
+ {m.username}{m.message} + {fmtTime(m.ts)} +
+ {/each} + {#if activeRoom.messages.length === 0} +
Quiet in here so far — say something.
+ {/if} +
+
+
+ +
+ {:else} +
+

Rooms

+

Join one from the list — the roster and the running conversation land here.

+
+ {/if} +
+ {#if activeRoom} +
+
{activeRoom.users.length} in room
+
+ {#each [...activeRoom.users].sort((a, b) => a.username.localeCompare(b.username)) as u (u.username)} + + {/each} +
+
+ {/if} +
+ + + {:else if tab === "chat"} +
+
+
Conversations
+ {#if convs.length === 0} +
No messages yet — start one from a buddy's Chat button, or wait for someone to write.
+ {:else} + {#each convs as c (c.u)} + + {/each} + {/if} +
+
+ {#if activeChat} +
+ {#each activeChat.messages as m (m.ts + m.text)} + {m.text}{fmtTime(m.ts)} + {/each} +
+
+
+ +
+ {:else} +
+

Chat

+

Pick a conversation — or open one from any buddy or room user.

+
+ {/if} +
+
+ + + {:else} +
+
+ + {#if buddyErr}{buddyErr}{/if} +
+ {#if buddyRows.length === 0} +
+

No buddies yet

+

Add users you care about — their status and shares stay watched while you're online, with one-click browse and chat.

+
+ {:else} +
+ BuddyStatusSharedSpeed +
+ {#each buddyRows as row (row.u)} + {@const chip = statusChip(row.st)} +
+ + + {row.u} + + {chip.label} + {row.st.file_count != null ? `${row.st.file_count.toLocaleString()} files` : "—"} + {row.st.avg_speed ? fmtSpeed(row.st.avg_speed) : "—"} + + + + + +
+ {/each} + {/if} + {/if} +
+ +