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} +
+ +