diff --git a/CHANGELOG.md b/CHANGELOG.md index 6030cfa..a0dd10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ ### Added +- **Friends screen**: Added a new Library entry and full Friends screen backed by spotatui.com, including friend-code display, online filtering, inline name search, and live now-playing status for followed users. +- **Friend management flows**: Added add-friend support by friend code or username search, plus unfollow actions and periodic background refresh while the Friends screen is open. +- **Wayland clipboard support**: Enabled Linux `arboard` Wayland data-control support so clipboard operations like copying the friend code work reliably on Wayland sessions. + +### Fixed + +- **Friends key handling**: Friends-specific keys and inline input now take precedence over conflicting global shortcuts, so add/search/copy/filter actions stay local to the Friends UI. - **Playlist track search**: Added playlist-internal track search from playlist track tables with ``, client-side matching across track title, artists, and album, loading feedback while large playlists are scanned, and `q`/the configured back key to clear the active playlist filter and restore the cached playlist view ([#198](https://github.com/LargeModGames/spotatui/issues/198)). ### Fixed diff --git a/Cargo.lock b/Cargo.lock index aff3a6a..350eab9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,6 +196,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -1366,6 +1367,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "ed25519" version = "2.2.3" @@ -1602,6 +1609,12 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.8" @@ -2940,7 +2953,7 @@ dependencies = [ "priority-queue", "protobuf", "protobuf-json-mapping", - "quick-xml", + "quick-xml 0.38.4", "rand 0.9.2", "rand_distr", "rsa", @@ -3771,6 +3784,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.45.0", +] + [[package]] name = "outref" version = "0.5.2" @@ -3879,6 +3902,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -4260,6 +4294,15 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -5066,7 +5109,7 @@ dependencies = [ "http", "indicatif", "log", - "quick-xml", + "quick-xml 0.38.4", "regex", "reqwest 0.13.2", "self-replace", @@ -6053,6 +6096,17 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6515,6 +6569,76 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa75f400b7f719bcd68b3f47cd939ba654cedeef690f486db71331eec4c6a406" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.3", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" +dependencies = [ + "bitflags 2.10.0", + "rustix 1.1.3", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374f6b70e8e0d6bf9461a32988fd553b59ff630964924dad6e4a4eb6bd538d17" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -7277,6 +7401,24 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.3", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index ead5547..b649600 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,6 @@ clap = { version = "4.6", features = ["cargo"] } clap_complete = "4.6" unicode-width = "0.2.2" backtrace = "0.3.76" -arboard = "3.4" crossterm = "0.29" tui-equalizer = "0.2.0-alpha" tui-bar-graph = "0.3.3" @@ -70,13 +69,16 @@ url = "2.5" keepawake = "0.6" [target.'cfg(all(target_os = "linux", not(target_env = "musl")))'.dependencies] +arboard = { version = "3.4", features = ["wayland-data-control"] } pipewire = { version = "0.10", optional = true } librespot-playback = { version = "0.8", optional = true, default-features = false, features = ["alsa-backend"] } [target.'cfg(all(target_os = "linux", target_env = "musl"))'.dependencies] +arboard = { version = "3.4", features = ["wayland-data-control"] } librespot-playback = { version = "0.8", optional = true, default-features = false, features = ["rodio-backend"] } [target.'cfg(target_os = "windows")'.dependencies] +arboard = "3.4" # On Windows we default to rodio for audio output. # Without this, librespot-playback falls back to the `pipe` sink which writes raw audio bytes # to stdout (breaking the TUI and producing no audible output). @@ -91,6 +93,7 @@ mpris-server = { version = "0.10", optional = true } # Rodio has compatibility issues with macOS CoreAudio and Bluetooth devices (AirPods, etc.) # causing SIGSEGV crashes. See Issue #9 and #20. [target.'cfg(target_os = "macos")'.dependencies] +arboard = "3.4" librespot-playback = { version = "0.8", optional = true, default-features = false, features = ["portaudio-backend"] } objc2-media-player = { version = "0.3", optional = true } objc2-foundation = { version = "0.3", optional = true } diff --git a/src/core/app.rs b/src/core/app.rs index 7f518a2..7915df4 100644 --- a/src/core/app.rs +++ b/src/core/app.rs @@ -37,9 +37,10 @@ use std::{ use arboard::Clipboard; use log::info; -pub const LIBRARY_OPTIONS: [&str; 6] = [ +pub const LIBRARY_OPTIONS: [&str; 7] = [ "Discover", "Recently Played", + "Friends", "Liked Songs", "Albums", "Artists", @@ -243,6 +244,7 @@ pub enum ActiveBlock { Queue, Party, CreatePlaylistForm, + Friends, } #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] @@ -280,8 +282,56 @@ pub enum RouteId { Queue, Party, CreatePlaylist, + Friends, } +// ── Friends feature ─────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +pub struct FriendEntry { + pub id: String, + pub name: String, + pub is_online: bool, + pub now_playing: Option, + /// Total listening time in milliseconds (from spotatui.com) + #[allow(dead_code)] + pub listening_ms: u64, + /// Total number of listens tracked on spotatui.com + #[allow(dead_code)] + pub total_listens: u64, +} + +#[derive(Clone, Debug)] +pub struct FriendNowPlaying { + pub title: String, + pub artists: String, +} + +/// A user returned from the username/code search. +#[derive(Clone, Debug)] +pub struct FriendSearchResult { + pub id: String, + pub name: String, + pub is_following: bool, +} + +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub enum FriendFilter { + #[default] + All, + Online, +} + +/// Which tab is active in the "Add Friend" dialog. +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub enum FriendAddMode { + #[default] + Code, + Search, +} + +// ───────────────────────────────────────────────────────────────────────────── + #[derive(Clone, Copy, PartialEq, Debug)] pub enum AnnouncementLevel { Info, @@ -887,6 +937,34 @@ pub struct App { #[cfg(all(feature = "mpris", target_os = "linux"))] pub mpris_manager: Option>, + // Friends screen state + /// All friends fetched from spotatui.com (follows list) + pub friends: Vec, + /// Whether friends are currently loading from the API + pub friends_loading: bool, + /// Own friend code fetched from spotatui.com + pub friend_code: Option, + /// Cursor position in the friends list + pub friend_selected_index: usize, + /// Active filter (All / Online) + pub friend_filter: FriendFilter, + /// Inline search / filter input on the Friends screen + pub friend_search_input: Vec, + /// Whether the "Add Friend" overlay dialog is open + pub friend_add_dialog_visible: bool, + /// Which tab is active inside the add-friend dialog + pub friend_add_mode: FriendAddMode, + /// Input buffer for the "add by friend code" text field + pub friend_add_input: Vec, + /// Input buffer for the "search by username" text field in the add dialog + pub friend_user_search_input: Vec, + /// Results from searching users by name + pub friend_user_search_results: Vec, + /// Selected row in the user-search results list + pub friend_user_search_selected: usize, + /// Timestamp of the last time friends were refreshed (for periodic polling) + pub last_friends_refresh_at: Instant, + // Create Playlist form state pub create_playlist_name: Vec, pub create_playlist_name_idx: usize, @@ -1075,6 +1153,19 @@ impl Default for App { mpris_manager: None, #[cfg(feature = "cover-art")] cover_art: crate::tui::cover_art::CoverArt::new(), + friends: Vec::new(), + friends_loading: false, + friend_code: None, + friend_selected_index: 0, + friend_filter: FriendFilter::All, + friend_search_input: Vec::new(), + friend_add_dialog_visible: false, + friend_add_mode: FriendAddMode::Code, + friend_add_input: Vec::new(), + friend_user_search_input: Vec::new(), + friend_user_search_results: Vec::new(), + friend_user_search_selected: 0, + last_friends_refresh_at: Instant::now(), create_playlist_name: Vec::new(), create_playlist_name_idx: 0, create_playlist_name_cursor: 0, @@ -1173,6 +1264,20 @@ impl App { self.playlist_picker_selected_index = 0; } + pub fn clear_friend_add_dialog_state(&mut self) { + self.friend_add_dialog_visible = false; + self.friend_add_mode = FriendAddMode::Code; + self.friend_add_input.clear(); + self.friend_user_search_input.clear(); + self.friend_user_search_results.clear(); + self.friend_user_search_selected = 0; + } + + pub fn open_friend_add_dialog(&mut self) { + self.clear_friend_add_dialog_state(); + self.friend_add_dialog_visible = true; + } + pub fn clear_dialog_state(&mut self) { self.dialog = None; self.confirm = false; @@ -1457,6 +1562,16 @@ impl App { self.dispatch(IoEvent::SyncPlayback); } + // Periodic friends refresh: re-fetch when the Friends screen is active, every 30 seconds. + if self.get_current_route().id == RouteId::Friends + && self.last_friends_refresh_at.elapsed() >= Duration::from_secs(30) + && !self.friends_loading + && self.user_config.behavior.sync_token.is_some() + { + self.last_friends_refresh_at = Instant::now(); + self.dispatch(IoEvent::GetFriends); + } + if let Some(expires_at) = self.status_message_expires_at { if Instant::now() >= expires_at { self.status_message = None; diff --git a/src/infra/history.rs b/src/infra/history.rs index ff5d1b9..bc5a66b 100644 --- a/src/infra/history.rs +++ b/src/infra/history.rs @@ -16,6 +16,9 @@ use tokio::sync::Mutex; const HISTORY_SUBDIR: &str = "history"; const LISTENS_FILE_NAME: &str = "listens.jsonl"; const CLOUD_SYNC_URL: &str = "https://spotatui.com/api/sync"; +const NOW_PLAYING_SYNC_URL: &str = "https://spotatui.com/api/sync/now-playing"; +/// Heartbeat interval: must be well under the 5-minute online threshold used by the website. +const NOW_PLAYING_HEARTBEAT_SECS: u64 = 60; const MAX_INTERVAL_MS: u64 = 5_000; const REPLAY_RESET_THRESHOLD_MS: u128 = 15_000; const REPLAY_PREVIOUS_PROGRESS_FLOOR_MS: u128 = 30_000; @@ -87,6 +90,12 @@ struct HistoryCollector { last_observed_at: Option, } +#[derive(Serialize)] +struct NowPlayingPayload<'a> { + title: &'a str, + artists: &'a [String], +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RecapPeriod { SevenDays, @@ -101,25 +110,32 @@ pub fn spawn_history_collector(app: Arc>) { let mut collector = HistoryCollector::default(); let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1)); let mut last_auto_check = Instant::now(); + let http_client = reqwest::Client::new(); // Check on startup perform_auto_recap_check(&app); // Sync history to cloud on startup - let sync_token_opt = if let Ok(app_guard) = app.try_lock() { + let sync_token_opt: Option = if let Ok(app_guard) = app.try_lock() { app_guard.user_config.behavior.sync_token.clone() } else { None }; - if let Some(token) = sync_token_opt { + if let Some(ref token) = sync_token_opt { + let token = token.clone(); + let client = http_client.clone(); tokio::spawn(async move { - if let Err(e) = sync_history_to_cloud(&token).await { + if let Err(e) = sync_history_to_cloud_with_client(&client, &token).await { log::warn!("failed to run startup history cloud sync: {}", e); } }); } + // Now-playing tracking state + let mut last_now_playing: Option<(String, Vec)> = None; + let mut last_heartbeat: Option = None; + loop { interval.tick().await; @@ -134,6 +150,41 @@ pub fn spawn_history_collector(app: Arc>) { continue; }; + // Now-playing sync: update on track change and heartbeat while playing + if let Some(ref token) = sync_token_opt { + let snap_for_np = snapshot + .as_ref() + .filter(|s| s.item_kind == PlaybackItemKind::Track); + match snap_for_np { + Some(snap) => { + let current_id = (snap.metadata.title.clone(), snap.metadata.artists.clone()); + let track_changed = last_now_playing.as_ref() != Some(¤t_id); + let heartbeat_due = snap.is_playing + && last_heartbeat + .map(|t| t.elapsed().as_secs() >= NOW_PLAYING_HEARTBEAT_SECS) + .unwrap_or(false); + + if track_changed || heartbeat_due { + last_now_playing = Some(current_id.clone()); + last_heartbeat = Some(Instant::now()); + let token_clone = token.clone(); + let client = http_client.clone(); + let (title, artists) = current_id; + tokio::spawn(async move { + if let Err(e) = + sync_now_playing_to_cloud(&client, &token_clone, &title, &artists).await + { + log::warn!("failed to sync now-playing: {}", e); + } + }); + } + } + None => { + last_now_playing = None; + } + } + } + if let Err(error) = collector.observe(snapshot) { log::warn!("listening history collector failed: {}", error); } @@ -1470,6 +1521,102 @@ fn last_synced_file_path() -> Result { ) } +/// Post the current now-playing track to the cloud dashboard. +/// Uses a shared HTTP client to reuse the connection pool across frequent calls. +async fn sync_now_playing_to_cloud( + client: &reqwest::Client, + sync_token: &str, + title: &str, + artists: &[String], +) -> Result<()> { + let payload = NowPlayingPayload { title, artists }; + let response = client + .post(NOW_PLAYING_SYNC_URL) + .header("Authorization", format!("Bearer {}", sync_token)) + .json(&payload) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(anyhow!("now-playing sync failed ({}): {}", status, body)); + } + Ok(()) +} + +/// Clear the now-playing status when the TUI exits. +pub async fn clear_now_playing_from_cloud(sync_token: &str) -> Result<()> { + let client = reqwest::Client::new(); + let response = client + .delete(NOW_PLAYING_SYNC_URL) + .header("Authorization", format!("Bearer {}", sync_token)) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(anyhow!("clear now-playing failed ({}): {}", status, body)); + } + Ok(()) +} + +/// Internal helper used by the history collector's shared HTTP client. +async fn sync_history_to_cloud_with_client( + client: &reqwest::Client, + sync_token: &str, +) -> Result<()> { + let path = last_synced_file_path()?; + + use chrono::TimeZone; + let last_synced_at = match fs::read_to_string(&path) { + Ok(content) => DateTime::parse_from_rfc3339(content.trim()) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc.timestamp_opt(0, 0).unwrap()), + Err(_) => Utc.timestamp_opt(0, 0).unwrap(), + }; + + let listens = load_listens()?; + let new_listens: Vec<&ListenRecord> = listens + .iter() + .filter(|record| record.ended_at > last_synced_at) + .collect(); + + if new_listens.is_empty() { + log::info!("no new listening history records to sync"); + return Ok(()); + } + + let response = client + .post(CLOUD_SYNC_URL) + .header("Authorization", format!("Bearer {}", sync_token)) + .json(&new_listens) + .send() + .await?; + + if response.status().is_success() { + if let Some(last_record) = new_listens.last() { + fs::write(&path, last_record.ended_at.to_rfc3339())?; + } + log::info!( + "successfully synchronized listening history to cloud ({} tracks)", + new_listens.len() + ); + } else { + let status = response.status(); + let err_body = response.text().await.unwrap_or_default(); + log::warn!( + "failed to synchronize history: {} (status {})", + err_body, + status + ); + return Err(anyhow!("Sync failed: {}", err_body)); + } + + Ok(()) +} + pub async fn sync_history_to_cloud(sync_token: &str) -> Result<()> { let path = last_synced_file_path()?; @@ -1567,4 +1714,12 @@ mod tests { fn cloud_sync_uses_public_spotatui_domain() { assert_eq!(CLOUD_SYNC_URL, "https://spotatui.com/api/sync"); } + + #[test] + fn now_playing_uses_public_spotatui_domain() { + assert_eq!( + NOW_PLAYING_SYNC_URL, + "https://spotatui.com/api/sync/now-playing" + ); + } } diff --git a/src/infra/network/friends.rs b/src/infra/network/friends.rs new file mode 100644 index 0000000..46ad208 --- /dev/null +++ b/src/infra/network/friends.rs @@ -0,0 +1,348 @@ +use crate::core::app::{FriendEntry, FriendNowPlaying, FriendSearchResult}; +use crate::infra::network::Network; +use anyhow::Result; +use log::{info, warn}; +use serde::Deserialize; + +const FRIENDS_URL: &str = "https://spotatui.com/api/friends"; +const PROFILE_URL: &str = "https://spotatui.com/api/profile"; +const USERS_SEARCH_URL: &str = "https://spotatui.com/api/users/search"; + +// ── Response shapes from the spotatui.com API ───────────────────────────────── + +#[derive(Debug, Deserialize)] +struct NowPlayingData { + title: String, + artists: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PublicUserData { + id: String, + name: String, + is_online: bool, + now_playing: Option, + listening_ms: Option, + total_listens: Option, +} + +#[derive(Debug, Deserialize)] +struct FriendsResponse { + friends: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProfileResponse { + friend_code: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SearchUserData { + id: String, + name: String, + #[serde(default)] + is_following: bool, +} + +#[derive(Debug, Deserialize)] +struct UsersSearchResponse { + users: Vec, +} + +// ── Read sync token from App state ──────────────────────────────────────────── + +/// Read the sync token by briefly locking the app. +/// Call this before any `.await` branches that need the token. +async fn read_sync_token(network: &Network) -> Option { + let app = network.app.lock().await; + app.user_config.behavior.sync_token.clone() +} + +// ── Actual HTTP functions ───────────────────────────────────────────────────── + +async fn fetch_profile(token: &str) -> Result { + let client = reqwest::Client::new(); + let resp = client + .get(PROFILE_URL) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!( + "profile fetch failed ({}): {}", + status, + body + )); + } + + let data: ProfileResponse = resp.json().await?; + Ok(data.friend_code) +} + +async fn fetch_friends(token: &str) -> Result> { + let client = reqwest::Client::new(); + let resp = client + .get(FRIENDS_URL) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!( + "friends fetch failed ({}): {}", + status, + body + )); + } + + let data: FriendsResponse = resp.json().await?; + let entries = data + .friends + .into_iter() + .map(|u| FriendEntry { + id: u.id, + name: u.name, + is_online: u.is_online, + now_playing: u.now_playing.map(|np| FriendNowPlaying { + title: np.title, + artists: np.artists, + }), + listening_ms: u.listening_ms.unwrap_or(0), + total_listens: u.total_listens.unwrap_or(0), + }) + .collect(); + Ok(entries) +} + +async fn post_add_friend_by_code(token: &str, friend_code: &str) -> Result<()> { + let body = serde_json::json!({ "friendCode": friend_code }); + post_friend_request(token, body).await +} + +async fn post_add_friend_by_user_id(token: &str, user_id: &str) -> Result<()> { + let body = serde_json::json!({ "userId": user_id }); + post_friend_request(token, body).await +} + +async fn post_friend_request(token: &str, body: serde_json::Value) -> Result<()> { + let client = reqwest::Client::new(); + let resp = client + .post(FRIENDS_URL) + .header("Authorization", format!("Bearer {}", token)) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + // Try to parse a JSON error message + let msg = serde_json::from_str::(&text) + .ok() + .and_then(|v| v["error"].as_str().map(String::from)) + .unwrap_or(text); + return Err(anyhow::anyhow!("{} (HTTP {})", msg, status)); + } + + Ok(()) +} + +async fn delete_friend(token: &str, user_id: &str) -> Result<()> { + let client = reqwest::Client::new(); + let body = serde_json::json!({ "userId": user_id }); + let resp = client + .delete(FRIENDS_URL) + .header("Authorization", format!("Bearer {}", token)) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + let msg = serde_json::from_str::(&text) + .ok() + .and_then(|v| v["error"].as_str().map(String::from)) + .unwrap_or(text); + return Err(anyhow::anyhow!("{} (HTTP {})", msg, status)); + } + + Ok(()) +} + +async fn fetch_user_search(token: &str, query: &str) -> Result> { + if query.trim().is_empty() { + return Ok(vec![]); + } + + let client = reqwest::Client::new(); + let resp = client + .get(USERS_SEARCH_URL) + .header("Authorization", format!("Bearer {}", token)) + .query(&[("q", query)]) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("user search failed ({}): {}", status, body)); + } + + let data: UsersSearchResponse = resp.json().await?; + let results = data + .users + .into_iter() + .map(|u| FriendSearchResult { + id: u.id, + name: u.name, + is_following: u.is_following, + }) + .collect(); + Ok(results) +} + +// ── Convenience wrappers that extract the sync token before the async work ──── +// +// These free functions are called from the IoEvent match in `Network::handle_network_event`. +// Each one grabs the token from App state, then delegates to the proper HTTP helper. + +pub async fn handle_get_friend_code(network: &mut Network) { + let token = match read_sync_token(network).await { + Some(t) => t, + None => return, + }; + match fetch_profile(&token).await { + Ok(code) => { + let mut app = network.app.lock().await; + app.friend_code = Some(code); + } + Err(e) => warn!("friends: failed to fetch friend code: {}", e), + } +} + +pub async fn handle_get_friends(network: &mut Network) { + let token = match read_sync_token(network).await { + Some(t) => t, + None => { + let mut app = network.app.lock().await; + app.friends_loading = false; + return; + } + }; + { + let mut app = network.app.lock().await; + app.friends_loading = true; + } + match fetch_friends(&token).await { + Ok(friends) => { + let mut app = network.app.lock().await; + let len = friends.len(); + app.friends = friends; + app.friends_loading = false; + if app.friend_selected_index >= len && len > 0 { + app.friend_selected_index = len - 1; + } + info!("friends: loaded {} friends", len); + } + Err(e) => { + let mut app = network.app.lock().await; + app.friends_loading = false; + warn!("friends: failed to load friends: {}", e); + } + } +} + +pub async fn handle_add_friend_by_code(network: &mut Network, friend_code: String) { + let token = match read_sync_token(network).await { + Some(t) => t, + None => return, + }; + match post_add_friend_by_code(&token, &friend_code).await { + Ok(_) => { + handle_get_friends(network).await; + network + .show_status_message(format!("Added friend (code: {})", friend_code), 4) + .await; + } + Err(e) => { + network + .show_status_message(format!("Could not add friend: {}", e), 5) + .await; + } + } +} + +pub async fn handle_add_friend_by_user_id(network: &mut Network, user_id: String) { + let token = match read_sync_token(network).await { + Some(t) => t, + None => return, + }; + match post_add_friend_by_user_id(&token, &user_id).await { + Ok(_) => { + handle_get_friends(network).await; + network + .show_status_message("Added friend".to_string(), 4) + .await; + } + Err(e) => { + network + .show_status_message(format!("Could not add friend: {}", e), 5) + .await; + } + } +} + +pub async fn handle_unfollow_friend(network: &mut Network, user_id: String) { + let token = match read_sync_token(network).await { + Some(t) => t, + None => return, + }; + match delete_friend(&token, &user_id).await { + Ok(_) => { + handle_get_friends(network).await; + network + .show_status_message("Unfollowed".to_string(), 3) + .await; + } + Err(e) => { + network + .show_status_message(format!("Failed to unfollow: {}", e), 5) + .await; + } + } +} + +pub async fn handle_search_friend_users(network: &mut Network, query: String) { + let token = match read_sync_token(network).await { + Some(t) => t, + None => return, + }; + match fetch_user_search(&token, &query).await { + Ok(results) => { + let mut app = network.app.lock().await; + let current_query: String = app.friend_user_search_input.iter().collect(); + if current_query == query { + app.friend_user_search_results = results; + app.friend_user_search_selected = 0; + } + } + Err(e) => { + let mut app = network.app.lock().await; + let current_query: String = app.friend_user_search_input.iter().collect(); + if current_query == query { + app.friend_user_search_results.clear(); + app.friend_user_search_selected = 0; + } + warn!("friends: user search failed: {}", e); + } + } +} diff --git a/src/infra/network/mod.rs b/src/infra/network/mod.rs index 6a050f5..3e82e39 100644 --- a/src/infra/network/mod.rs +++ b/src/infra/network/mod.rs @@ -1,3 +1,4 @@ +pub mod friends; pub mod library; pub mod metadata; pub mod playback; @@ -135,6 +136,18 @@ pub enum IoEvent { SearchTracksForPlaylist(String), /// Create a new playlist with the given name and track IDs CreateNewPlaylist(String, Vec>), + /// Fetch the current user's own friend code from spotatui.com + GetFriendCode, + /// Fetch the current user's friends list from spotatui.com + GetFriends, + /// Add a friend by their 6-character friend code + AddFriendByCode(String), + /// Add a friend by their spotatui.com user ID + AddFriendByUserId(String), + /// Unfollow a friend by their spotatui.com user ID + UnfollowFriend(String), + /// Search spotatui.com users by display name or friend code + SearchFriendUsers(String), } pub struct Network { @@ -418,6 +431,24 @@ impl Network { IoEvent::CreateNewPlaylist(name, track_ids) => { self.create_new_playlist(name, track_ids).await; } + IoEvent::GetFriendCode => { + friends::handle_get_friend_code(self).await; + } + IoEvent::GetFriends => { + friends::handle_get_friends(self).await; + } + IoEvent::AddFriendByCode(code) => { + friends::handle_add_friend_by_code(self, code).await; + } + IoEvent::AddFriendByUserId(user_id) => { + friends::handle_add_friend_by_user_id(self, user_id).await; + } + IoEvent::UnfollowFriend(user_id) => { + friends::handle_unfollow_friend(self, user_id).await; + } + IoEvent::SearchFriendUsers(query) => { + friends::handle_search_friend_users(self, query).await; + } }; { diff --git a/src/tui/handlers/friends.rs b/src/tui/handlers/friends.rs new file mode 100644 index 0000000..0bcd61f --- /dev/null +++ b/src/tui/handlers/friends.rs @@ -0,0 +1,219 @@ +use super::common_key_events; +use crate::core::app::{App, FriendAddMode, FriendFilter}; +use crate::infra::network::IoEvent; +use crate::tui::event::Key; +use crate::tui::ui::friends::filtered_friends; + +pub fn handler(key: Key, app: &mut App) { + // When the add-friend dialog is open, route all keys there. + if app.friend_add_dialog_visible { + handle_add_dialog(key, app); + return; + } + + // When the search input has focus (non-empty), handle character input inline. + if !app.friend_search_input.is_empty() { + match key { + Key::Esc => { + // Clear search and return focus to the list + app.friend_search_input.clear(); + return; + } + Key::Backspace => { + app.friend_search_input.pop(); + // Reset selected index when the list changes + app.friend_selected_index = 0; + return; + } + Key::Char(c) if c != '\n' => { + app.friend_search_input.push(c); + app.friend_selected_index = 0; + return; + } + _ => {} + } + } + + match key { + // Navigation + k if common_key_events::down_event(k) => move_down(app), + k if common_key_events::up_event(k) => move_up(app), + k if common_key_events::high_event(k) => app.friend_selected_index = 0, + k if common_key_events::low_event(k) => { + let count = filtered_count(app); + if count > 0 { + app.friend_selected_index = count - 1; + } + } + + // Copy own friend code to clipboard + Key::Char('c') => copy_friend_code(app), + + // Open add-friend dialog + Key::Char('a') => app.open_friend_add_dialog(), + + // Unfollow selected friend (no confirm for now — status message acts as feedback) + Key::Char('u') => unfollow_selected(app), + + // Tab: cycle between All / Online filter + Key::Tab => { + app.friend_filter = match app.friend_filter { + FriendFilter::All => FriendFilter::Online, + FriendFilter::Online => FriendFilter::All, + }; + app.friend_selected_index = 0; + } + + // Type directly into search when idle (any unbound character filters the list) + Key::Char(c) if c != '\n' => { + app.friend_search_input.push(c); + app.friend_selected_index = 0; + } + + // Backspace clears last search character + Key::Backspace if !app.friend_search_input.is_empty() => { + app.friend_search_input.pop(); + app.friend_selected_index = 0; + } + + // Esc: pop navigation (handled upstream, but guard in case) + Key::Esc => { + app.friend_search_input.clear(); + app.pop_navigation_stack(); + } + + _ => {} + } +} + +// ── Add-friend dialog handler ───────────────────────────────────────────────── + +fn handle_add_dialog(key: Key, app: &mut App) { + match key { + // Close dialog + Key::Esc => close_dialog(app), + + // Switch between Code / Search tabs + Key::Tab => { + app.friend_add_mode = match app.friend_add_mode { + FriendAddMode::Code => FriendAddMode::Search, + FriendAddMode::Search => FriendAddMode::Code, + }; + } + + // Submit + Key::Enter => match app.friend_add_mode { + FriendAddMode::Code => { + let code: String = app.friend_add_input.iter().collect(); + let code = code.trim().to_string(); + if !code.is_empty() { + app.dispatch(IoEvent::AddFriendByCode(code)); + app.clear_friend_add_dialog_state(); + } + } + FriendAddMode::Search => { + let idx = app.friend_user_search_selected; + if let Some(result) = app.friend_user_search_results.get(idx) { + let user_id = result.id.clone(); + app.dispatch(IoEvent::AddFriendByUserId(user_id)); + app.clear_friend_add_dialog_state(); + } + } + }, + + Key::Backspace => match app.friend_add_mode { + FriendAddMode::Code => { + app.friend_add_input.pop(); + } + FriendAddMode::Search => { + app.friend_user_search_input.pop(); + let query: String = app.friend_user_search_input.iter().collect(); + if query.len() >= 2 { + app.dispatch(IoEvent::SearchFriendUsers(query)); + } else { + app.friend_user_search_results.clear(); + } + } + }, + + // Navigate search results + k if app.friend_add_mode == FriendAddMode::Search && common_key_events::down_event(k) => { + let count = app.friend_user_search_results.len(); + if count > 0 { + app.friend_user_search_selected = (app.friend_user_search_selected + 1).min(count - 1); + } + } + + k if app.friend_add_mode == FriendAddMode::Search + && common_key_events::up_event(k) + && app.friend_user_search_selected > 0 => + { + app.friend_user_search_selected -= 1; + } + + Key::Char(c) if c != '\n' => match app.friend_add_mode { + FriendAddMode::Code => { + app.friend_add_input.push(c); + } + FriendAddMode::Search => { + app.friend_user_search_input.push(c); + let query: String = app.friend_user_search_input.iter().collect(); + if query.len() >= 2 { + app.dispatch(IoEvent::SearchFriendUsers(query)); + } + } + }, + + _ => {} + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn close_dialog(app: &mut App) { + app.clear_friend_add_dialog_state(); +} + +fn filtered_count(app: &App) -> usize { + filtered_friends(app).len() +} + +fn move_down(app: &mut App) { + let count = filtered_count(app); + if count == 0 { + return; + } + app.friend_selected_index = (app.friend_selected_index + 1).min(count - 1); +} + +fn move_up(app: &mut App) { + if app.friend_selected_index > 0 { + app.friend_selected_index -= 1; + } +} + +fn copy_friend_code(app: &mut App) { + let Some(code) = app.friend_code.clone() else { + app.set_status_message("Friend code not loaded yet", 3); + return; + }; + + let Some(clipboard) = &mut app.clipboard else { + app.set_status_message("Clipboard not available", 3); + return; + }; + + if clipboard.set_text(code.clone()).is_ok() { + app.set_status_message(format!("Copied friend code: {}", code), 3); + } else { + app.set_status_message("Failed to copy to clipboard", 3); + } +} + +fn unfollow_selected(app: &mut App) { + let filtered = filtered_friends(app); + if let Some(friend) = filtered.get(app.friend_selected_index) { + let user_id = friend.id.clone(); + app.dispatch(IoEvent::UnfollowFriend(user_id)); + } +} diff --git a/src/tui/handlers/library.rs b/src/tui/handlers/library.rs index 36a5b76..3f6a95f 100644 --- a/src/tui/handlers/library.rs +++ b/src/tui/handlers/library.rs @@ -33,36 +33,49 @@ pub fn handler(key: Key, app: &mut App) { // `library` should probably be an array of structs with enums rather than just using indexes // like this Key::Enter => match app.library.selected_index { + // Discover 0 => { app.push_navigation_stack(RouteId::Discover, ActiveBlock::Discover); } - // Recently Played, + // Recently Played 1 => { app.dispatch(IoEvent::GetRecentlyPlayed); app.push_navigation_stack(RouteId::RecentlyPlayed, ActiveBlock::RecentlyPlayed); } - // Liked Songs, + // Friends 2 => { + app.push_navigation_stack(RouteId::Friends, ActiveBlock::Friends); + // Load friend code + friends list on first open (or if empty) + if app.friend_code.is_none() { + app.dispatch(IoEvent::GetFriendCode); + } + if app.friends.is_empty() && !app.friends_loading { + app.dispatch(IoEvent::GetFriends); + } + app.last_friends_refresh_at = std::time::Instant::now(); + } + // Liked Songs + 3 => { app.reset_saved_tracks_view(); app.dispatch(IoEvent::GetCurrentSavedTracks(None)); app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); } - // Albums, - 3 => { + // Albums + 4 => { app.dispatch(IoEvent::GetCurrentUserSavedAlbums(None)); app.push_navigation_stack(RouteId::AlbumList, ActiveBlock::AlbumList); } - // Artists, - 4 => { + // Artists + 5 => { app.dispatch(IoEvent::GetFollowedArtists(None)); app.push_navigation_stack(RouteId::Artists, ActiveBlock::Artists); } - // Podcasts, - 5 => { + // Podcasts + 6 => { app.dispatch(IoEvent::GetCurrentUserSavedShows(None)); app.push_navigation_stack(RouteId::Podcasts, ActiveBlock::Podcasts); } - // This is required because Rust can't tell if this pattern in exhaustive + // This is required because Rust can't tell if this pattern is exhaustive _ => {} }, _ => (), diff --git a/src/tui/handlers/mod.rs b/src/tui/handlers/mod.rs index b6087be..f44eb11 100644 --- a/src/tui/handlers/mod.rs +++ b/src/tui/handlers/mod.rs @@ -13,6 +13,7 @@ mod discover; mod empty; mod episode_table; mod error_screen; +mod friends; mod help_menu; mod home; mod input; @@ -62,6 +63,19 @@ fn open_settings(app: &mut App) { app.push_navigation_stack(RouteId::Settings, ActiveBlock::Settings); } +fn should_route_friends_before_globals(key: Key, app: &App) -> bool { + if app.get_current_route().active_block != ActiveBlock::Friends { + return false; + } + + app.friend_add_dialog_visible + || !app.friend_search_input.is_empty() + || matches!( + key, + Key::Char('a') | Key::Char('c') | Key::Char('u') | Key::Tab + ) +} + pub fn handle_app(key: Key, app: &mut App) { if app.get_current_route().active_block == ActiveBlock::Settings && (app.settings_unsaved_prompt_visible || app.settings_edit_mode) @@ -83,6 +97,13 @@ pub fn handle_app(key: Key, app: &mut App) { return; } + // Friends has a few local keys that conflict with globals, plus inline input modes + // that need first chance to consume typed characters. + if should_route_friends_before_globals(key, app) { + handle_block_events(key, app); + return; + } + if app.maybe_activate_open_settings_fallback(key) { open_settings(app); if app.pending_keybinding_persist.is_some() { @@ -400,6 +421,9 @@ fn handle_block_events(key: Key, app: &mut App) { ActiveBlock::CreatePlaylistForm => { create_playlist::handler(key, app); } + ActiveBlock::Friends => { + friends::handler(key, app); + } } } @@ -450,6 +474,9 @@ fn handle_escape(app: &mut App) { ActiveBlock::CreatePlaylistForm => { create_playlist::handler(Key::Esc, app); } + ActiveBlock::Friends => { + friends::handler(Key::Esc, app); + } _ => { app.set_current_route_state(Some(ActiveBlock::Empty), None); } @@ -525,7 +552,16 @@ mod tests { idtypes::PlaylistId, CurrentlyPlayingType, Device, PlayableId, PlayableItem, }; - use std::{sync::mpsc::channel, time::SystemTime}; + use std::{ + sync::mpsc::{channel, TryRecvError}, + time::SystemTime, + }; + + fn friends_app() -> App { + let mut app = App::default(); + app.push_navigation_stack(RouteId::Friends, ActiveBlock::Friends); + app + } #[test] fn global_shift_w_adds_current_track_from_anywhere() { @@ -615,6 +651,96 @@ mod tests { assert_eq!(app.input, vec!['F']); } + #[test] + fn friends_a_opens_add_dialog_before_global_album_jump() { + let (tx, rx) = channel(); + let mut app = App::new(tx, UserConfig::new(), SystemTime::now()); + let track = full_track("0000000000000000000001", "Track 1"); + app.current_playback_context = Some(CurrentPlaybackContext { + device: Device { + id: Some("device-1".to_string()), + is_active: true, + is_private_session: false, + is_restricted: false, + name: "Desk Speaker".to_string(), + _type: DeviceType::Computer, + volume_percent: Some(42), + }, + repeat_state: RepeatState::Off, + shuffle_state: false, + context: None, + timestamp: Utc::now(), + progress: None, + is_playing: false, + item: Some(PlayableItem::Track(track)), + currently_playing_type: CurrentlyPlayingType::Track, + actions: Actions::default(), + }); + app.push_navigation_stack(RouteId::Friends, ActiveBlock::Friends); + + handle_app(Key::Char('a'), &mut app); + + assert!(app.friend_add_dialog_visible); + assert_eq!(app.get_current_route().active_block, ActiveBlock::Friends); + assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); + } + + #[test] + fn friends_c_prefers_friend_code_copy_over_global_song_copy() { + let mut app = friends_app(); + app.friend_code = Some("jay-1234".to_string()); + app.clipboard = None; + + handle_app(Key::Char('c'), &mut app); + + assert_eq!( + app.status_message.as_deref(), + Some("Clipboard not available") + ); + assert!(!app.friend_add_dialog_visible); + } + + #[test] + fn friends_search_buffer_keeps_globally_bound_characters_local() { + let mut app = friends_app(); + app.friend_search_input = vec!['j']; + + handle_app(Key::Char('a'), &mut app); + handle_app(Key::Char('c'), &mut app); + + assert_eq!(app.friend_search_input, vec!['j', 'a', 'c']); + assert!(!app.friend_add_dialog_visible); + assert!(app.status_message.is_none()); + } + + #[test] + fn friends_without_local_state_still_allows_non_conflicting_globals() { + let (tx, rx) = channel(); + let mut app = App::new(tx, UserConfig::new(), SystemTime::now()); + app.push_navigation_stack(RouteId::Friends, ActiveBlock::Friends); + + handle_app(app.user_config.keys.next_track, &mut app); + + match rx.recv().unwrap() { + IoEvent::NextTrack => {} + _ => panic!("unexpected event"), + } + assert!(app.friend_search_input.is_empty()); + assert!(!app.friend_add_dialog_visible); + } + + #[test] + fn friends_add_dialog_keeps_priority_for_conflicting_keys() { + let mut app = friends_app(); + app.open_friend_add_dialog(); + + handle_app(Key::Char('c'), &mut app); + + assert!(app.friend_add_dialog_visible); + assert_eq!(app.friend_add_input, vec!['c']); + assert!(app.status_message.is_none()); + } + #[test] fn ctrl_f_in_playlist_track_table_opens_playlist_search_input() { let mut app = App::default(); diff --git a/src/tui/runner.rs b/src/tui/runner.rs index 9fbaa5d..3f0543a 100644 --- a/src/tui/runner.rs +++ b/src/tui/runner.rs @@ -738,6 +738,9 @@ pub async fn start_ui( if let Err(e) = crate::infra::history::sync_history_to_cloud(&token).await { log::warn!("failed to run exit history cloud sync: {}", e); } + if let Err(e) = crate::infra::history::clear_now_playing_from_cloud(&token).await { + log::warn!("failed to clear now-playing on exit: {}", e); + } } reset_window_title(&mut window_title_state)?; diff --git a/src/tui/ui/friends.rs b/src/tui/ui/friends.rs new file mode 100644 index 0000000..40ea80a --- /dev/null +++ b/src/tui/ui/friends.rs @@ -0,0 +1,536 @@ +use crate::core::app::{ActiveBlock, App, FriendAddMode, FriendEntry, FriendFilter}; +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph}, + Frame, +}; + +use super::util::{get_color, truncate_text}; + +pub fn draw_friends(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { + let current_route = app.get_current_route(); + let highlight_state = ( + current_route.active_block == ActiveBlock::Friends, + current_route.hovered_block == ActiveBlock::Friends, + ); + + let outer_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(get_color(highlight_state, app.user_config.theme)) + .title(Span::styled( + " Friends ", + get_color(highlight_state, app.user_config.theme).add_modifier(Modifier::BOLD), + )); + + let inner = outer_block.inner(layout_chunk); + f.render_widget(outer_block, layout_chunk); + + // No sync token → show account-required prompt + if app.user_config.behavior.sync_token.is_none() { + draw_no_token_prompt(f, app, inner); + return; + } + + // Split inner area: friend-code card | tabs+list | help bar + let [top_area, list_area, help_area] = inner.layout(&Layout::vertical([ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(1), + ])); + + draw_friend_code_card(f, app, top_area); + draw_friends_body(f, app, list_area); + draw_help_bar(f, app, help_area); + + // Add-friend overlay rendered on top + if app.friend_add_dialog_visible { + draw_add_friend_dialog(f, app, layout_chunk); + } +} + +// ── No-token prompt ─────────────────────────────────────────────────────────── + +fn draw_no_token_prompt(f: &mut Frame<'_>, app: &App, area: Rect) { + let theme = app.user_config.theme; + + let lines = vec![ + Line::default(), + Line::default(), + Line::from(Span::styled( + "Account Required", + Style::default() + .fg(theme.banner) + .add_modifier(Modifier::BOLD), + )), + Line::default(), + Line::from(Span::styled( + "Friends require a spotatui web account to sync your", + Style::default().fg(theme.text), + )), + Line::from(Span::styled( + "listening data and connect with other users.", + Style::default().fg(theme.text), + )), + Line::default(), + Line::from(vec![ + Span::styled(" Sign up at: ", Style::default().fg(theme.inactive)), + Span::styled( + "https://spotatui.com", + Style::default().fg(theme.hint).add_modifier(Modifier::BOLD), + ), + ]), + Line::default(), + Line::from(Span::styled( + "After signing up, copy your sync token from the dashboard", + Style::default().fg(theme.inactive), + )), + Line::from(Span::styled( + "and paste it into Settings → Behavior → sync_token", + Style::default().fg(theme.inactive), + )), + Line::default(), + Line::from(Span::styled( + "Press Esc to go back", + Style::default().fg(theme.hint), + )), + ]; + + let para = Paragraph::new(lines).alignment(ratatui::layout::Alignment::Center); + f.render_widget(para, area); +} + +// ── Friend code card ────────────────────────────────────────────────────────── + +fn draw_friend_code_card(f: &mut Frame<'_>, app: &App, area: Rect) { + let theme = app.user_config.theme; + + let code_text = app.friend_code.as_deref().unwrap_or("Loading..."); + + let hint = " c — copy"; + + let line = Line::from(vec![ + Span::styled( + " YOUR CODE ", + Style::default() + .fg(theme.inactive) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + code_text, + Style::default().fg(theme.hint).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(hint, Style::default().fg(theme.inactive)), + ]); + + let card = Paragraph::new(line).block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.active)), + ); + + f.render_widget(card, area); +} + +// ── Main body: filter tabs + friend list ────────────────────────────────────── + +fn draw_friends_body(f: &mut Frame<'_>, app: &App, area: Rect) { + let theme = app.user_config.theme; + + // Split into a thin tab/control row and the list below + let [controls_area, list_area] = area.layout(&Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + ])); + + // Controls row: [All (n)] [Online (n)] ... search hint ... [+ Add Friend] + draw_filter_tabs(f, app, controls_area); + + // The actual friends list + let filtered = filtered_friends(app); + + if filtered.is_empty() { + let msg = if !app.friend_search_input.is_empty() { + "No friends match your search" + } else if app.friends_loading { + "Loading friends..." + } else if app.friends.is_empty() { + "No friends yet — press 'a' to add one!" + } else { + "No friends online right now" + }; + let para = Paragraph::new(Span::styled(msg, Style::default().fg(theme.inactive))) + .alignment(ratatui::layout::Alignment::Center); + // Center vertically a bit + let [_, center, _] = list_area.layout(&Layout::vertical([ + Constraint::Percentage(30), + Constraint::Length(1), + Constraint::Min(1), + ])); + f.render_widget(para, center); + return; + } + + draw_friend_list(f, app, list_area, &filtered); +} + +fn draw_filter_tabs(f: &mut Frame<'_>, app: &App, area: Rect) { + let theme = app.user_config.theme; + let online_count = app.friends.iter().filter(|f| f.is_online).count(); + let all_count = app.friends.len(); + + let all_style = if app.friend_filter == FriendFilter::All { + Style::default() + .fg(theme.selected) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.inactive) + }; + let online_style = if app.friend_filter == FriendFilter::Online { + Style::default() + .fg(theme.selected) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.inactive) + }; + + // Build search preview (inline: any unbound key types into the filter) + let search_str: String = app.friend_search_input.iter().collect(); + let search_hint = if search_str.is_empty() { + "search…".to_string() + } else { + format!("/{}/", search_str) + }; + + let line = Line::from(vec![ + Span::styled(format!(" All ({}) ", all_count), all_style), + Span::styled(" │ ", Style::default().fg(theme.inactive)), + Span::styled(format!(" Online ({}) ", online_count), online_style), + Span::styled(" ", Style::default()), + Span::styled(search_hint, Style::default().fg(theme.inactive)), + Span::styled(" ", Style::default()), + Span::styled("+ Add Friend", Style::default().fg(theme.active)), + Span::raw(" "), + ]); + + let para = Paragraph::new(line); + f.render_widget(para, area); +} + +fn draw_friend_list(f: &mut Frame<'_>, app: &App, area: Rect, friends: &[&FriendEntry]) { + let theme = app.user_config.theme; + + let items: Vec = friends + .iter() + .enumerate() + .map(|(i, friend)| { + let is_selected = i == app.friend_selected_index; + + // Online indicator + let online_span = if friend.is_online { + Span::styled("● ", Style::default().fg(theme.active)) + } else { + Span::styled("○ ", Style::default().fg(theme.inactive)) + }; + + // Name + let name_style = if is_selected { + Style::default() + .fg(theme.selected) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text) + }; + let name_span = Span::styled( + format!("{:<20}", truncate_text(&friend.name, 20)), + name_style, + ); + + // Now-playing or status + let np_spans = if let Some(np) = &friend.now_playing { + vec![ + Span::styled("▶ ", Style::default().fg(theme.active)), + Span::styled( + truncate_text(&np.title, 28), + Style::default().fg(theme.hint), + ), + Span::styled( + format!(" — {}", truncate_text(&np.artists, 20)), + Style::default().fg(theme.inactive), + ), + ] + } else if friend.is_online { + vec![Span::styled("idle", Style::default().fg(theme.inactive))] + } else { + vec![Span::styled("offline", Style::default().fg(theme.inactive))] + }; + + let mut spans = vec![online_span, name_span, Span::raw(" ")]; + spans.extend(np_spans); + + ListItem::new(Line::from(spans)) + }) + .collect(); + + let mut state = ListState::default(); + state.select(Some( + app + .friend_selected_index + .min(friends.len().saturating_sub(1)), + )); + + let list = List::new(items) + .highlight_style( + Style::default() + .fg(theme.selected) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + + f.render_stateful_widget(list, area, &mut state); +} + +// ── Help bar ────────────────────────────────────────────────────────────────── + +fn draw_help_bar(f: &mut Frame<'_>, app: &App, area: Rect) { + let theme = app.user_config.theme; + + let line = Line::from(vec![ + hint_span("↑/↓", theme), + Span::styled(" Navigate ", Style::default().fg(theme.inactive)), + hint_span("c", theme), + Span::styled(" Copy code ", Style::default().fg(theme.inactive)), + hint_span("a", theme), + Span::styled(" Add friend ", Style::default().fg(theme.inactive)), + hint_span("u", theme), + Span::styled(" Unfollow ", Style::default().fg(theme.inactive)), + hint_span("Tab", theme), + Span::styled(" Filter ", Style::default().fg(theme.inactive)), + Span::styled("type to search ", Style::default().fg(theme.inactive)), + hint_span("Esc", theme), + Span::styled(" Back", Style::default().fg(theme.inactive)), + ]); + + f.render_widget(Paragraph::new(line), area); +} + +fn hint_span(key: &'static str, theme: crate::core::user_config::Theme) -> Span<'static> { + Span::styled( + key, + Style::default().fg(theme.hint).add_modifier(Modifier::BOLD), + ) +} + +// ── Add-Friend dialog overlay ───────────────────────────────────────────────── + +fn draw_add_friend_dialog(f: &mut Frame<'_>, app: &App, parent: Rect) { + let theme = app.user_config.theme; + + // Center a 50×18 dialog box + let dialog_width = 52u16.min(parent.width.saturating_sub(4)); + let dialog_height = 14u16.min(parent.height.saturating_sub(4)); + let x = parent.x + (parent.width.saturating_sub(dialog_width)) / 2; + let y = parent.y + (parent.height.saturating_sub(dialog_height)) / 2; + let dialog_area = Rect::new(x, y, dialog_width, dialog_height); + + f.render_widget(Clear, dialog_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.active)) + .title(Span::styled( + " Add Friend ", + Style::default() + .fg(theme.active) + .add_modifier(Modifier::BOLD), + )); + + let inner = block.inner(dialog_area); + f.render_widget(block, dialog_area); + + // Mode tabs + let [tabs_area, content_area, help_area] = inner.layout(&Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ])); + + // Tab row + let code_style = if app.friend_add_mode == FriendAddMode::Code { + Style::default() + .fg(theme.selected) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) + } else { + Style::default().fg(theme.inactive) + }; + let search_style = if app.friend_add_mode == FriendAddMode::Search { + Style::default() + .fg(theme.selected) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) + } else { + Style::default().fg(theme.inactive) + }; + let tabs_line = Line::from(vec![ + Span::styled(" By Friend Code ", code_style), + Span::styled(" │ ", Style::default().fg(theme.inactive)), + Span::styled(" Search by Name ", search_style), + ]); + f.render_widget(Paragraph::new(tabs_line), tabs_area); + + match app.friend_add_mode { + FriendAddMode::Code => draw_add_by_code(f, app, content_area), + FriendAddMode::Search => draw_add_by_search(f, app, content_area), + } + + // Footer hints + let footer_line = Line::from(vec![ + hint_span("Tab", theme), + Span::styled(" Switch ", Style::default().fg(theme.inactive)), + hint_span("Enter", theme), + Span::styled(" Add ", Style::default().fg(theme.inactive)), + hint_span("Esc", theme), + Span::styled(" Cancel", Style::default().fg(theme.inactive)), + ]); + f.render_widget(Paragraph::new(footer_line), help_area); +} + +fn draw_add_by_code(f: &mut Frame<'_>, app: &App, area: Rect) { + let theme = app.user_config.theme; + + let input: String = app.friend_add_input.iter().collect(); + let display = if input.is_empty() { + "Enter friend code...".to_string() + } else { + input.clone() + }; + let style = if input.is_empty() { + Style::default().fg(theme.inactive) + } else { + Style::default().fg(theme.hint).add_modifier(Modifier::BOLD) + }; + + let [_, input_row, hint_row, _] = area.layout(&Layout::vertical([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + Constraint::Min(0), + ])); + + let input_widget = Paragraph::new(Span::styled(display, style)) + .alignment(ratatui::layout::Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.active)), + ); + f.render_widget(input_widget, input_row); + + let hint = Paragraph::new(Span::styled( + "Type code then press Enter", + Style::default().fg(theme.inactive), + )) + .alignment(ratatui::layout::Alignment::Center); + f.render_widget(hint, hint_row); +} + +fn draw_add_by_search(f: &mut Frame<'_>, app: &App, area: Rect) { + let theme = app.user_config.theme; + + let [input_row, results_area] = area.layout(&Layout::vertical([ + Constraint::Length(3), + Constraint::Min(1), + ])); + + // Search input field + let search_str: String = app.friend_user_search_input.iter().collect(); + let search_display = if search_str.is_empty() { + "Type a username or code...".to_string() + } else { + search_str + }; + let search_style = if app.friend_user_search_input.is_empty() { + Style::default().fg(theme.inactive) + } else { + Style::default().fg(theme.text) + }; + let search_widget = Paragraph::new(Span::styled(search_display, search_style)).block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.inactive)), + ); + f.render_widget(search_widget, input_row); + + // Results list + if app.friend_user_search_results.is_empty() { + let msg = if app.friend_user_search_input.is_empty() { + "" + } else { + "No users found" + }; + f.render_widget( + Paragraph::new(Span::styled(msg, Style::default().fg(theme.inactive))) + .alignment(ratatui::layout::Alignment::Center), + results_area, + ); + return; + } + + let items: Vec = app + .friend_user_search_results + .iter() + .enumerate() + .map(|(i, r)| { + let is_sel = i == app.friend_user_search_selected; + let prefix = if is_sel { "▶ " } else { " " }; + let style = if is_sel { + Style::default() + .fg(theme.selected) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text) + }; + let following_tag = if r.is_following { " [following]" } else { "" }; + ListItem::new(Line::from(vec![ + Span::styled(prefix, Style::default().fg(theme.selected)), + Span::styled(format!("{}{}", r.name, following_tag), style), + ])) + }) + .collect(); + + let mut state = ListState::default(); + state.select(Some(app.friend_user_search_selected)); + let list = List::new(items).highlight_style( + Style::default() + .fg(theme.selected) + .add_modifier(Modifier::BOLD), + ); + f.render_stateful_widget(list, results_area, &mut state); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Return the subset of friends that pass the active filter and search query. +pub fn filtered_friends(app: &App) -> Vec<&FriendEntry> { + let search_str: String = app.friend_search_input.iter().collect(); + let q = search_str.to_lowercase(); + + app + .friends + .iter() + .filter(|f| { + let passes_filter = match app.friend_filter { + FriendFilter::All => true, + FriendFilter::Online => f.is_online, + }; + let passes_search = q.is_empty() || f.name.to_lowercase().contains(&q); + passes_filter && passes_search + }) + .collect() +} diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs index e94a383..a7e518d 100644 --- a/src/tui/ui/mod.rs +++ b/src/tui/ui/mod.rs @@ -2,6 +2,7 @@ pub mod artist; pub mod audio_analysis; pub mod create_playlist; pub mod discover; +pub mod friends; pub mod help; pub mod home; pub mod library; @@ -22,6 +23,7 @@ use ratatui::{ pub use self::artist::draw_artist_albums; pub use self::create_playlist::draw_create_playlist_form; pub use self::discover::draw_discover; +pub use self::friends::draw_friends; pub use self::home::draw_home; pub use self::library::draw_user_block; #[cfg(feature = "cover-art")] @@ -119,6 +121,9 @@ pub fn draw_routes(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { RouteId::Discover => { draw_discover(f, app, content_area); } + RouteId::Friends => { + draw_friends(f, app, content_area); + } RouteId::Artists => { draw_artist_table(f, app, content_area); } diff --git a/src/tui/ui/util.rs b/src/tui/ui/util.rs index 826f6f5..82c474d 100644 --- a/src/tui/ui/util.rs +++ b/src/tui/ui/util.rs @@ -116,6 +116,16 @@ pub fn display_track_progress(progress: u128, track_duration: Duration) -> Strin format!("{}/{} (-{})", progress_display, duration, remaining,) } +pub fn truncate_text(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + s.to_string() + } else { + let mut truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect(); + truncated.push('…'); + truncated + } +} + // `percentage` param needs to be between 0 and 1 pub fn get_percentage_width(width: u16, percentage: f32) -> u16 { let padding = 3;