diff --git a/.all-contributorsrc b/.all-contributorsrc index 7514a2ac..ddc053d7 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -213,6 +213,42 @@ "contributions": [ "doc" ] + }, + { + "login": "1knth", + "name": "knth", + "avatar_url": "https://avatars.githubusercontent.com/u/115324660?v=4", + "profile": "https://github.com/1knth", + "contributions": [ + "code" + ] + }, + { + "login": "SkyeVault", + "name": "Lorelei Noble", + "avatar_url": "https://avatars.githubusercontent.com/u/64027767?v=4", + "profile": "http://www.dev.arynwood.com", + "contributions": [ + "code" + ] + }, + { + "login": "benalleng", + "name": "Ben Allen", + "avatar_url": "https://avatars.githubusercontent.com/u/108441023?v=4", + "profile": "https://benalleng.com", + "contributions": [ + "code" + ] + }, + { + "login": "shilicioo", + "name": "shilicioo", + "avatar_url": "https://avatars.githubusercontent.com/u/56956072?v=4", + "profile": "https://github.com/shilicioo", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 1e365a37..3c87cf8c 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -165,7 +165,7 @@ jobs: merge-multiple: true - name: Create Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: artifacts/* generate_release_notes: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 02cd9a35..0ffbd354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [v0.38.1] - 2026-04-24 + +### Added + +- **Create Playlist flow**: Added an in-app create-playlist screen with name entry, track search, selected-track management, and playlist creation through the Spotify API ([#193](https://github.com/LargeModGames/spotatui/pull/193)). +- **Global like/unlike hotkey**: Added a configurable global keybinding (`F` by default) to like or unlike the currently playing track from any screen, complementing the existing context-specific `s` key. +- **Mouse input disable mode**: Added `behavior.disable_mouse_inputs` support for users who want to run spotatui without mouse interactions enabled ([#200](https://github.com/LargeModGames/spotatui/pull/200)). + +### Changed + +- **Fullscreen playbar resizing**: `LyricsView` and `CoverArtView` now honor the existing playbar height setting, so the lower player can be resized or fully hidden with the same keybindings used on the main screen (fixes [#208](https://github.com/LargeModGames/spotatui/issues/208)). +- **Settings UI simplification**: Simplified settings screen block handling as part of the mouse-input disable work ([#200](https://github.com/LargeModGames/spotatui/pull/200)). + +### Fixed + +- **PKCE refresh-token persistence**: Fixed refreshed auth tokens being written without preserving an existing refresh token, which could cause spotatui to require re-login after every reboot ([#217](https://github.com/LargeModGames/spotatui/pull/217)). +- **Startup auth cache recovery**: Fixed startup behavior around failed token-cache loading and prevented in-memory `refresh_token = None` states from replacing a valid cached refresh token. +- **Followed Artists list appears empty**: Fixed Artists view in Library to correctly display followed artists ([#220](https://github.com/LargeModGames/spotatui/pull/220); fixes [#219](https://github.com/LargeModGames/spotatui/issues/219)). +- **Hidden fullscreen cover-art centering**: Fixed the fullscreen cover art image rendering slightly off-center when the playbar was completely hidden. +- **Create Playlist results controls**: Improved keyboard focus and tab controls in the create-playlist search results and selected-track panes. +- **Volume display glitch on rapid changes**: Fixed the volume percentage briefly reverting to an old value after the user changed it, especially noticeable when spamming volume up/down. The UI now always shows the user's intended volume until Spotify's API confirms it matches. + +### Internal + +- **Dependency maintenance**: Bumped `tui-bar-graph`, `self_update`, grouped Rust minor dependencies, `rustls-webpki`, and `openssl` ([#197](https://github.com/LargeModGames/spotatui/pull/197), [#204](https://github.com/LargeModGames/spotatui/pull/204), [#205](https://github.com/LargeModGames/spotatui/pull/205), [#213](https://github.com/LargeModGames/spotatui/pull/213), [#224](https://github.com/LargeModGames/spotatui/pull/224), [#225](https://github.com/LargeModGames/spotatui/pull/225)). +- **CI maintenance**: Bumped `softprops/action-gh-release` from `2` to `3` in the Actions dependency group ([#212](https://github.com/LargeModGames/spotatui/pull/212)). +- **Lint/format cleanup**: Fixed clippy errors and rustfmt issues after the post-`v0.38.0` changes ([#222](https://github.com/LargeModGames/spotatui/pull/222)). + + ## [v0.38.0] - 2026-03-23 ### Added diff --git a/Cargo.lock b/Cargo.lock index 78c70091..af5f01ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -737,9 +737,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" +checksum = "406e68b4de5c59cfb8f750a7cbd4d31ae153788b8352167c1e5f4fc26e8c91e9" dependencies = [ "clap", ] @@ -2713,9 +2713,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libfuzzer-sys" @@ -2936,7 +2936,7 @@ dependencies = [ "log", "oauth2", "open", - "reqwest", + "reqwest 0.12.28", "thiserror 2.0.18", "url", ] @@ -3162,9 +3162,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -3440,7 +3440,7 @@ dependencies = [ "getrandom 0.2.17", "http", "rand 0.8.5", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_path_to_error", @@ -3644,9 +3644,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ "bitflags 2.10.0", "cfg-if", @@ -3685,9 +3685,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" dependencies = [ "cc", "libc", @@ -4391,7 +4391,7 @@ dependencies = [ "itertools 0.14.0", "kasuari", "lru", - "strum", + "strum 0.27.2", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -4439,7 +4439,7 @@ dependencies = [ "itertools 0.14.0", "line-clipping", "ratatui-core", - "strum", + "strum 0.27.2", "time", "unicode-segmentation", "unicode-width", @@ -4613,7 +4613,6 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", "http", "http-body", "http-body-util", @@ -4646,6 +4645,45 @@ dependencies = [ "webpki-roots 1.0.5", ] +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "rgb" version = "0.8.52" @@ -4732,7 +4770,7 @@ dependencies = [ "async-trait", "log", "maybe-async", - "reqwest", + "reqwest 0.12.28", "serde_json", "thiserror 2.0.18", ] @@ -4753,7 +4791,7 @@ dependencies = [ "enum_dispatch", "serde", "serde_json", - "strum", + "strum 0.27.2", "thiserror 2.0.18", ] @@ -4844,9 +4882,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -4957,9 +4995,9 @@ dependencies = [ [[package]] name = "self_update" -version = "0.43.1" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6644febaa58f323b28f7321d04e24d0020d117c27619ab869d6abdf76be9aac6" +checksum = "2e79722b5a505d4ddc77527455a97244e9e8c4c07533ff44cf4421cce7bb6d17" dependencies = [ "either", "flate2", @@ -4968,7 +5006,7 @@ dependencies = [ "log", "quick-xml", "regex", - "reqwest", + "reqwest 0.13.2", "self-replace", "semver", "serde", @@ -5205,12 +5243,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5251,7 +5289,7 @@ dependencies = [ [[package]] name = "spotatui" -version = "0.38.0" +version = "0.38.1" dependencies = [ "anyhow", "arboard", @@ -5289,7 +5327,7 @@ dependencies = [ "ratatui", "ratatui-image", "realfft", - "reqwest", + "reqwest 0.12.28", "rspotify", "self_update", "serde", @@ -5336,7 +5374,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -5351,6 +5398,18 @@ dependencies = [ "syn", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5683,9 +5742,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", @@ -5700,9 +5759,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -5939,13 +5998,14 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tui-bar-graph" -version = "0.3.1" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97505cbb8633edb0f727724b72a71dec4b810499cdbda7c10727b57e83581950" +checksum = "072ce7dd14a4cffcff2727d22b4a643b0eb06564bc49b279cb870e3d196a9dbf" dependencies = [ "colorgrad", + "document-features", "ratatui-core", - "strum", + "strum 0.28.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1bec63ce..40f35f68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ documentation = "https://github.com/LargeModGames/spotatui" repository = "https://github.com/LargeModGames/spotatui" keywords = ["spotify", "tui", "cli", "terminal"] categories = ["command-line-utilities"] -version = "0.38.0" +version = "0.38.1" authors = ["LargeModGames "] edition = "2021" license = "MIT" @@ -34,16 +34,16 @@ backtrace = "0.3.76" arboard = "3.4" crossterm = "0.29" tui-equalizer = "0.2.0-alpha" -tui-bar-graph = "0.3.1" +tui-bar-graph = "0.3.3" colorgrad = "0.8.0" -tokio = { version = "1.50", features = ["full"] } +tokio = { version = "1.51", features = ["full"] } rand = "0.8.4" anyhow = "1.0.102" chrono = "0.4.44" log = "0.4.28" fern = "0.7.1" open = "5.3" -self_update = { version = "0.43", features = ["archive-tar", "archive-zip", "compression-flate2", "compression-zip-deflate"] } +self_update = { version = "0.44", features = ["archive-tar", "archive-zip", "compression-flate2", "compression-zip-deflate"] } reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } openssl = { version = "0.10", optional = true } cpal = { version = "0.17", optional = true } diff --git a/README.md b/README.md index 8eb9d1ba..1a3be89f 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,12 @@ Originally forked from [spotify-tui](https://github.com/Rigellute/spotify-tui) b Higor Prado
Higor Prado

💻 Vitali Kaplich
Vitali Kaplich

📖 + + knth
knth

💻 + Lorelei Noble
Lorelei Noble

💻 + Ben Allen
Ben Allen

💻 + shilicioo
shilicioo

💻 + diff --git a/SECURITY.md b/SECURITY.md index 3ba186fc..f8e37e4d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,8 +6,8 @@ We maintain security fixes only for the current stable release stream. | Version | Supported | | ------- | ------------------ | -| 0.37 | :white_check_mark: | -| < 0.37 | :x: | +| 0.38 | :white_check_mark: | +| < 0.38 | :x: | ## Reporting a Vulnerability diff --git a/src/core/app.rs b/src/core/app.rs index 8f5d522d..41ea630f 100644 --- a/src/core/app.rs +++ b/src/core/app.rs @@ -242,6 +242,7 @@ pub enum ActiveBlock { SortMenu, Queue, Party, + CreatePlaylistForm, } #[derive(Clone, PartialEq, Debug)] @@ -271,6 +272,7 @@ pub enum RouteId { HelpMenu, Queue, Party, + CreatePlaylist, } #[derive(Clone, Copy, PartialEq, Debug)] @@ -509,6 +511,23 @@ pub enum PlaylistFolderItem { }, } +/// Which stage of the "Create Playlist" form we are on +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub enum CreatePlaylistStage { + #[default] + Name, + AddTracks, +} + +/// Which panel inside the AddTracks stage has focus +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub enum CreatePlaylistFocus { + #[default] + SearchInput, + SearchResults, + AddedTracks, +} + /// Settings screen category tabs #[derive(Clone, Copy, PartialEq, Debug, Default)] pub enum SettingsCategory { @@ -592,6 +611,13 @@ pub struct SettingItem { } pub struct App { + /// What the user actually wants the volume to be. We keep this around until + /// Spotify's API comes back with the same value — otherwise a slow poll + /// response can flash the old volume back on screen. + pub pending_volume: Option, + /// The last value we actually sent to the API. Lets us skip redundant + /// dispatches while we're just waiting for confirmation. + pub last_dispatched_volume: Option, pub instant_since_last_current_playback_poll: Instant, navigation_stack: Vec, pub spectrum_data: Option, @@ -779,12 +805,29 @@ pub struct App { pub saved_tracks_prefetch_generation: u64, /// Incremented every time the playlist track table is reloaded to guard stale prefetch tasks pub playlist_tracks_prefetch_generation: u64, + /// Tracks whether a ChangeVolume request is on its way to Spotify. + /// When true, we hold off on sending another one — rapid key presses + /// just update `pending_volume` and the latest value wins. + pub is_volume_change_in_flight: bool, /// Reference to the native streaming player for direct control (bypasses event channel) #[cfg(feature = "streaming")] pub streaming_player: Option>, /// Reference to MPRIS manager for emitting Seeked signals after native seeks #[cfg(all(feature = "mpris", target_os = "linux"))] pub mpris_manager: Option>, + + // Create Playlist form state + pub create_playlist_name: Vec, + pub create_playlist_name_idx: usize, + pub create_playlist_name_cursor: u16, + pub create_playlist_stage: CreatePlaylistStage, + pub create_playlist_tracks: Vec, + pub create_playlist_search_results: Vec, + pub create_playlist_search_input: Vec, + pub create_playlist_search_idx: usize, + pub create_playlist_search_cursor: u16, + pub create_playlist_selected_result: usize, + pub create_playlist_focus: CreatePlaylistFocus, } #[derive(Clone, Copy, PartialEq, Debug)] @@ -945,12 +988,26 @@ impl Default for App { _playlist_refresh_generation: 0, saved_tracks_prefetch_generation: 0, playlist_tracks_prefetch_generation: 0, + is_volume_change_in_flight: false, + pending_volume: None, + last_dispatched_volume: None, #[cfg(feature = "streaming")] streaming_player: None, #[cfg(all(feature = "mpris", target_os = "linux"))] mpris_manager: None, #[cfg(feature = "cover-art")] cover_art: crate::tui::cover_art::CoverArt::new(), + create_playlist_name: Vec::new(), + create_playlist_name_idx: 0, + create_playlist_name_cursor: 0, + create_playlist_stage: CreatePlaylistStage::Name, + create_playlist_tracks: Vec::new(), + create_playlist_search_results: Vec::new(), + create_playlist_search_input: Vec::new(), + create_playlist_search_idx: 0, + create_playlist_search_cursor: 0, + create_playlist_selected_result: 0, + create_playlist_focus: CreatePlaylistFocus::SearchInput, } } } @@ -1567,6 +1624,27 @@ impl App { } } + /// Picks up pending volume changes from the tick loop and sends them to Spotify. + /// + /// Skips dispatching if the previous request is still in flight, or if we + /// already sent this exact value and are just waiting for the API to confirm. + /// + /// We intentionally don't clear `pending_volume` here — it sticks around until + /// `get_current_playback` sees the matching value come back from the API. + pub fn flush_pending_volume(&mut self) { + if self.is_volume_change_in_flight { + return; // previous request still processing + } + if let Some(volume) = self.pending_volume { + if self.last_dispatched_volume == Some(volume) { + return; // already dispatched this value, waiting for API to confirm + } + self.is_volume_change_in_flight = true; + self.last_dispatched_volume = Some(volume); + self.dispatch(IoEvent::ChangeVolume(volume)); + } + } + pub fn get_recommendations_for_seed( &mut self, seed_artists: Option>, @@ -1604,70 +1682,99 @@ impl App { } } + /// Returns the volume the UI should show and volume-up/down should use as a base. + /// + /// If the user just pressed a volume key, we show their input (not what the API + /// says) because Spotify can be slow to reflect the change. Without this, you'd + /// see the percentage jump back to the old value for a split second before + /// correcting — especially noticeable when spamming volume up/down. + pub fn desired_volume(&self) -> u32 { + if let Some(pending) = self.pending_volume { + return pending as u32; + } + self + .current_playback_context + .as_ref() + .and_then(|c| c.device.volume_percent) + .unwrap_or(0) + } + + /// Bump volume up. Uses `desired_volume()` as the base so rapid presses + /// don't accidentally calculate from a stale API value. pub fn increase_volume(&mut self) { - if let Some(context) = self.current_playback_context.clone() { - let current_volume = context.device.volume_percent.unwrap_or(0) as u8; - let next_volume = min( - current_volume + self.user_config.behavior.volume_increment, - 100, - ); + let current_volume = self.desired_volume() as u8; + let next_volume = min( + current_volume + self.user_config.behavior.volume_increment, + 100, + ); - if next_volume != current_volume { - info!("increasing volume: {} -> {}", current_volume, next_volume); - // Use native streaming player for instant control (bypasses event channel latency) - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() { - if let Some(ref player) = self.streaming_player { - player.set_volume(next_volume); - - // Update UI state immediately - if let Some(ctx) = &mut self.current_playback_context { - ctx.device.volume_percent = Some(next_volume.into()); - } - self.user_config.behavior.volume_percent = next_volume; - let _ = self.user_config.save_config(); - return; + if next_volume != current_volume { + info!("increasing volume: {} -> {}", current_volume, next_volume); + // Use native streaming player for instant control (bypasses event channel latency) + #[cfg(feature = "streaming")] + if self.is_native_streaming_active_for_playback() { + if let Some(ref player) = self.streaming_player { + player.set_volume(next_volume); + + // Update UI state immediately + if let Some(ctx) = &mut self.current_playback_context { + ctx.device.volume_percent = Some(next_volume.into()); } + self.user_config.behavior.volume_percent = next_volume; + let _ = self.user_config.save_config(); + self.pending_volume = Some(next_volume); + return; } + } - // Fallback to API-based volume control for external devices + // Fallback to API-based volume control for external devices + // Coalesce: only dispatch if no request is already in flight + self.pending_volume = Some(next_volume); + if !self.is_volume_change_in_flight { + self.is_volume_change_in_flight = true; self.dispatch(IoEvent::ChangeVolume(next_volume)); } } } + /// Bump volume down. Uses `desired_volume()` as the base so rapid presses + /// don't accidentally calculate from a stale API value. pub fn decrease_volume(&mut self) { - if let Some(context) = self.current_playback_context.clone() { - let current_volume = context.device.volume_percent.unwrap_or(0) as i8; - let next_volume = max( - current_volume - self.user_config.behavior.volume_increment as i8, - 0, - ); + let current_volume = self.desired_volume() as i8; + let next_volume = max( + current_volume - self.user_config.behavior.volume_increment as i8, + 0, + ); - if next_volume != current_volume { - let next_volume_u8 = next_volume as u8; - info!( - "decreasing volume: {} -> {}", - current_volume, next_volume_u8 - ); + if next_volume != current_volume { + let next_volume_u8 = next_volume as u8; + info!( + "decreasing volume: {} -> {}", + current_volume, next_volume_u8 + ); - // Use native streaming player for instant control (bypasses event channel latency) - #[cfg(feature = "streaming")] - if self.is_native_streaming_active_for_playback() { - if let Some(ref player) = self.streaming_player { - player.set_volume(next_volume_u8); + // Use native streaming player for instant control (bypasses event channel latency) + #[cfg(feature = "streaming")] + if self.is_native_streaming_active_for_playback() { + if let Some(ref player) = self.streaming_player { + player.set_volume(next_volume_u8); - // Update UI state immediately - if let Some(ctx) = &mut self.current_playback_context { - ctx.device.volume_percent = Some(next_volume_u8.into()); - } - self.user_config.behavior.volume_percent = next_volume_u8; - let _ = self.user_config.save_config(); - return; + // Update UI state immediately + if let Some(ctx) = &mut self.current_playback_context { + ctx.device.volume_percent = Some(next_volume_u8.into()); } + self.user_config.behavior.volume_percent = next_volume_u8; + let _ = self.user_config.save_config(); + self.pending_volume = Some(next_volume_u8); + return; } + } - // Fallback to API-based volume control for external devices + // Fallback to API-based volume control for external devices + // Coalesce: only dispatch if no request is already in flight + self.pending_volume = Some(next_volume_u8); + if !self.is_volume_change_in_flight { + self.is_volume_change_in_flight = true; self.dispatch(IoEvent::ChangeVolume(next_volume_u8)); } } @@ -2830,6 +2937,12 @@ impl App { description: "Force search bar to take full width".to_string(), value: SettingValue::Bool(self.user_config.behavior.enforce_wide_search_bar), }, + SettingItem { + id: "behavior.disable_mouse_inputs".to_string(), + name: "Disable Mouse Inputs".to_string(), + description: "Disable mouse inputs for keyboard-only navigation".to_string(), + value: SettingValue::Bool(self.user_config.behavior.disable_mouse_inputs), + }, SettingItem { id: "behavior.set_window_title".to_string(), name: "Set Window Title".to_string(), @@ -3071,6 +3184,13 @@ impl App { description: "Show playback queue".to_string(), value: SettingValue::Key(key_to_string(&self.user_config.keys.show_queue)), }, + SettingItem { + id: "keys.like_track".to_string(), + name: "Like Track".to_string(), + description: "Toggle saved state for the currently playing track or episode" + .to_string(), + value: SettingValue::Key(key_to_string(&self.user_config.keys.like_track)), + }, SettingItem { id: "keys.copy_song_url".to_string(), name: "Copy Song URL".to_string(), @@ -3245,6 +3365,11 @@ impl App { self.user_config.behavior.enforce_wide_search_bar = *v; } } + "behavior.disable_mouse_inputs" => { + if let SettingValue::Bool(v) = &setting.value { + self.user_config.behavior.disable_mouse_inputs = *v; + } + } "behavior.set_window_title" => { if let SettingValue::Bool(v) = &setting.value { self.user_config.behavior.set_window_title = *v; @@ -3485,6 +3610,13 @@ impl App { } } } + "keys.like_track" => { + if let SettingValue::Key(v) = &setting.value { + if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { + self.user_config.keys.like_track = key; + } + } + } "keys.copy_song_url" => { if let SettingValue::Key(v) = &setting.value { if let Ok(key) = crate::core::user_config::parse_key_public(v.clone()) { diff --git a/src/core/config.rs b/src/core/config.rs index d2c7ab56..6866ca38 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -208,10 +208,8 @@ impl ClientConfig { ), ]; - let mut number = 1; - for item in instructions.iter() { - println!(" {}. {}", number, item); - number += 1; + for (number, item) in instructions.iter().enumerate() { + println!(" {}. {}", number + 1, item); } let fallback = ClientConfig::get_client_key_from_input("Fallback Client ID")?; diff --git a/src/core/layout.rs b/src/core/layout.rs index 58f9ce69..67f7be68 100644 --- a/src/core/layout.rs +++ b/src/core/layout.rs @@ -1,5 +1,5 @@ use crate::core::user_config::BehaviorConfig; -use ratatui::layout::Constraint; +use ratatui::layout::{Constraint, Layout, Rect}; /// Returns horizontal constraints for the [sidebar, content] split based on config. /// When sidebar_width_percent is 0, the sidebar is hidden (zero length). @@ -29,6 +29,25 @@ pub fn library_constraints(behavior: &BehaviorConfig) -> [Constraint; 2] { ] } +/// Returns the fullscreen content/playbar split used by lyrics and cover-art views. +/// +/// When `playbar_height_rows` is 0, the playbar is hidden and the content area fills the screen. +pub fn fullscreen_view_layout(behavior: &BehaviorConfig, area: Rect) -> (Rect, Option) { + if behavior.playbar_height_rows == 0 { + return (area, None); + } + + let chunks = Layout::vertical([ + Constraint::Min(0), + Constraint::Length(behavior.playbar_height_rows), + ]) + .split(area); + let content_area = chunks[0]; + let playbar_area = chunks[1]; + + (content_area, Some(playbar_area)) +} + #[cfg(test)] mod tests { use super::*; @@ -120,4 +139,26 @@ mod tests { assert_eq!(lib, Constraint::Percentage(100)); assert_eq!(playlists, Constraint::Percentage(0)); } + + #[test] + fn fullscreen_layout_hides_playbar_when_height_is_zero() { + let b = make_behavior_with(20, 0); + let area = Rect::new(2, 4, 80, 24); + + let (content, playbar) = fullscreen_view_layout(&b, area); + + assert_eq!(content, area); + assert!(playbar.is_none()); + } + + #[test] + fn fullscreen_layout_splits_content_and_playbar_when_height_is_set() { + let b = make_behavior_with(20, 6); + let area = Rect::new(2, 4, 80, 24); + + let (content, playbar) = fullscreen_view_layout(&b, area); + + assert_eq!(content, Rect::new(2, 4, 80, 18)); + assert_eq!(playbar, Some(Rect::new(2, 22, 80, 6))); + } } diff --git a/src/core/user_config.rs b/src/core/user_config.rs index d8b5998f..cd07537c 100644 --- a/src/core/user_config.rs +++ b/src/core/user_config.rs @@ -590,6 +590,7 @@ pub struct KeyBindingsString { open_settings: Option, save_settings: Option, listening_party: Option, + like_track: Option, } #[derive(Clone)] @@ -626,6 +627,7 @@ pub struct KeyBindings { pub open_settings: Key, pub save_settings: Key, pub listening_party: Key, + pub like_track: Key, } #[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -638,6 +640,7 @@ pub struct BehaviorConfigString { pub show_loading_indicator: Option, pub enforce_wide_search_bar: Option, pub enable_global_song_count: Option, + pub disable_mouse_inputs: Option, pub enable_discord_rpc: Option, pub discord_rpc_client_id: Option, pub enable_announcements: Option, @@ -677,6 +680,7 @@ pub struct BehaviorConfig { pub show_loading_indicator: bool, pub enforce_wide_search_bar: bool, pub enable_global_song_count: bool, + pub disable_mouse_inputs: bool, pub enable_discord_rpc: bool, pub discord_rpc_client_id: Option, pub enable_announcements: bool, @@ -776,6 +780,7 @@ impl UserConfig { }, save_settings: Key::Alt('s'), listening_party: Key::Ctrl('p'), + like_track: Key::Char('F'), }, behavior: BehaviorConfig { seek_milliseconds: 5 * 1000, @@ -786,6 +791,7 @@ impl UserConfig { show_loading_indicator: true, enforce_wide_search_bar: false, enable_global_song_count: true, + disable_mouse_inputs: false, enable_discord_rpc: true, discord_rpc_client_id: None, enable_announcements: true, @@ -886,6 +892,7 @@ impl UserConfig { to_keys!(open_settings); to_keys!(save_settings); to_keys!(listening_party); + to_keys!(like_track); Ok(()) } @@ -989,6 +996,10 @@ impl UserConfig { self.behavior.enable_global_song_count = enable_global_song_count; } + if let Some(disable_mouse_inputs) = behavior_config.disable_mouse_inputs { + self.behavior.disable_mouse_inputs = disable_mouse_inputs; + } + if let Some(enable_discord_rpc) = behavior_config.enable_discord_rpc { self.behavior.enable_discord_rpc = enable_discord_rpc; } @@ -1133,6 +1144,7 @@ impl UserConfig { show_loading_indicator: Some(self.behavior.show_loading_indicator), enforce_wide_search_bar: Some(self.behavior.enforce_wide_search_bar), enable_global_song_count: Some(self.behavior.enable_global_song_count), + disable_mouse_inputs: Some(self.behavior.disable_mouse_inputs), enable_discord_rpc: Some(self.behavior.enable_discord_rpc), discord_rpc_client_id: self.behavior.discord_rpc_client_id.clone(), enable_announcements: Some(self.behavior.enable_announcements), @@ -1234,6 +1246,7 @@ impl UserConfig { open_settings: Some(key_to_config_string(self.keys.open_settings)), save_settings: Some(key_to_config_string(self.keys.save_settings)), listening_party: Some(key_to_config_string(self.keys.listening_party)), + like_track: Some(key_to_config_string(self.keys.like_track)), }; // Helper to build theme config from current values diff --git a/src/infra/network/library.rs b/src/infra/network/library.rs index 72517e49..34835062 100644 --- a/src/infra/network/library.rs +++ b/src/infra/network/library.rs @@ -9,7 +9,7 @@ use reqwest::Method; use rspotify::model::{ idtypes::{AlbumId, LibraryId, PlaylistId, ShowId, TrackId, UserId}, page::Page, - playlist::PlaylistItem, + playlist::{PlaylistItem, SimplifiedPlaylist}, track::SavedTrack, PlayableItem, }; @@ -169,6 +169,7 @@ pub trait LibraryNetwork { async fn toggle_save_track(&mut self, track_id: rspotify::model::idtypes::PlayableId<'static>); async fn current_user_saved_tracks_contains(&mut self, ids: Vec>); async fn fetch_all_playlist_tracks_and_sort(&mut self, playlist_id: PlaylistId<'static>); + async fn create_new_playlist(&mut self, name: String, track_ids: Vec>); } // Private helper methods @@ -275,32 +276,37 @@ impl LibraryNetwork for Network { let mut first_page = None; loop { - match self - .spotify - .current_user_playlists_manual(Some(limit), Some(offset)) - .await + // Always use the compat path: parses raw JSON to serde_json::Value first, + // which silently deduplicates keys (last-wins). This handles the known Spotify + // API bug where "items" appears twice in the same JSON object. + let page = match super::requests::spotify_get_typed_compat_for::>( + &self.spotify, + "me/playlists", + &[("limit", limit.to_string()), ("offset", offset.to_string())], + ) + .await { - Ok(page) => { - if offset == 0 { - first_page = Some(page.clone()); - } - - if page.items.is_empty() { - break; - } - - all_playlists.extend(page.items); - - if page.next.is_none() { - break; - } - offset += limit; - } + Ok(page) => page, Err(e) => { self.handle_error(anyhow!(e)).await; return; } + }; + + if offset == 0 { + first_page = Some(page.clone()); } + + if page.items.is_empty() { + break; + } + + all_playlists.extend(page.items); + + if page.next.is_none() { + break; + } + offset += limit; } #[cfg(feature = "streaming")] @@ -766,6 +772,91 @@ impl LibraryNetwork for Network { sorter.sort_tracks(&mut all_tracks); let _ = app.apply_sorted_playlist_tracks_if_current(&playlist_id, all_tracks); } + + async fn create_new_playlist(&mut self, name: String, track_ids: Vec>) { + let user_id = { + let app = self.app.lock().await; + app.user.as_ref().map(|u| u.id.clone()) + }; + + let user_id = match user_id { + Some(id) => id, + None => { + self + .show_status_message("Cannot create playlist: not logged in".to_string(), 4) + .await; + return; + } + }; + + // Use raw API call to avoid rspotify deserializing FullPlaylist, which crashes when + // Spotify returns a duplicate "items" key in the response (known API migration bug). + let user_id_str = user_id.id().to_string(); + let create_path = format!("users/{}/playlists", user_id_str); + let create_body = json!({ + "name": name, + "public": false, + "collaborative": false, + "description": "Created with spotatui" + }); + let playlist_value = match spotify_api_request_json_for( + &self.spotify, + Method::POST, + &create_path, + &[], + Some(create_body), + ) + .await + { + Ok(v) => v, + Err(e) => { + self.handle_error(e).await; + return; + } + }; + + let playlist_id_str = match playlist_value.get("id").and_then(|v| v.as_str()) { + Some(id) => id.to_string(), + None => { + self + .show_status_message("Playlist created but could not get its ID".to_string(), 4) + .await; + return; + } + }; + + let playlist_id = match PlaylistId::from_id(playlist_id_str) { + Ok(id) => id.into_static(), + Err(e) => { + self.handle_error(anyhow!(e)).await; + return; + } + }; + + if !track_ids.is_empty() { + let items: Vec = track_ids + .iter() + .map(|id| rspotify::model::idtypes::PlayableId::Track(id.clone())) + .collect(); + if let Err(e) = self + .spotify + .playlist_add_items(playlist_id, items, None) + .await + { + self.handle_error(anyhow!(e)).await; + return; + } + } + + // Refresh playlists + { + let mut app = self.app.lock().await; + app.dispatch(IoEvent::GetPlaylists); + } + + let status = format!("Playlist \"{}\" created!", name); + self.show_status_message(status, 4).await; + } } #[cfg(test)] diff --git a/src/infra/network/mod.rs b/src/infra/network/mod.rs index 0bec27b7..e182466d 100644 --- a/src/infra/network/mod.rs +++ b/src/infra/network/mod.rs @@ -24,6 +24,7 @@ use rspotify::model::{ }; use rspotify::prelude::Id; use rspotify::AuthCodePkceSpotify; +use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::Mutex; @@ -140,6 +141,10 @@ pub enum IoEvent { /// Send a playback command to the party host (guest only, Phase 2) #[allow(dead_code)] PartyPlaybackCommand(sync::PlaybackAction), + /// Search tracks to add to a new playlist + SearchTracksForPlaylist(String), + /// Create a new playlist with the given name and track IDs + CreateNewPlaylist(String, Vec>), } pub struct Network { @@ -150,6 +155,7 @@ pub struct Network { pub app: Arc>, pub party_connection: Option, pub party_incoming_rx: Option>, + pub token_cache_path: PathBuf, } impl Network { @@ -158,6 +164,7 @@ impl Network { spotify: AuthCodePkceSpotify, client_config: ClientConfig, app: &Arc>, + token_cache_path: PathBuf, ) -> Self { Network { spotify, @@ -167,6 +174,7 @@ impl Network { app: Arc::clone(app), party_connection: None, party_incoming_rx: None, + token_cache_path, } } @@ -175,6 +183,7 @@ impl Network { spotify: AuthCodePkceSpotify, client_config: ClientConfig, app: &Arc>, + token_cache_path: PathBuf, ) -> Self { Network { spotify, @@ -184,6 +193,7 @@ impl Network { app: Arc::clone(app), party_connection: None, party_incoming_rx: None, + token_cache_path, } } @@ -413,6 +423,12 @@ impl Network { IoEvent::PartyPlaybackCommand(action) => { self.party_playback_command(action).await; } + IoEvent::SearchTracksForPlaylist(query) => { + self.search_tracks_for_playlist(query).await; + } + IoEvent::CreateNewPlaylist(name, track_ids) => { + self.create_new_playlist(name, track_ids).await; + } }; { @@ -461,6 +477,40 @@ impl Network { let mut app = self.app.lock().await; app.spotify_token_expiry = expiry; } + + // Persist the refreshed token so it survives a reboot. + // If Spotify did not return a new refresh_token, carry over the one already + // on disk so we never overwrite a valid refresh_token with null. + { + let mut token_lock = self + .spotify + .token + .lock() + .await + .expect("Failed to lock token"); + if let Some(ref mut token) = *token_lock { + if token.refresh_token.is_none() && self.token_cache_path.exists() { + if let Ok(old_json) = std::fs::read_to_string(&self.token_cache_path) { + if let Ok(old_token) = serde_json::from_str::(&old_json) { + token.refresh_token = old_token.refresh_token; + } + } + } + match serde_json::to_string_pretty(token) { + Ok(token_json) => { + if let Err(e) = std::fs::write(&self.token_cache_path, token_json) { + log::warn!("Failed to persist refreshed token: {}", e); + } else { + log::info!( + "refreshed token cached to {}", + self.token_cache_path.display() + ); + } + } + Err(e) => log::warn!("Failed to serialize refreshed token: {}", e), + } + } + } } async fn start_party(&mut self, control_mode: sync::ControlMode) { diff --git a/src/infra/network/playback.rs b/src/infra/network/playback.rs index d73ed611..46ce1c92 100644 --- a/src/infra/network/playback.rs +++ b/src/infra/network/playback.rs @@ -246,6 +246,22 @@ impl PlaybackNetwork for Network { } } + // Check if Spotify finally caught up to the user's volume change. + // If the API now returns what the user asked for, we can clear pending_volume + // and let the API take over again. If not, this response is stale — ignore it. + if let Some(pending) = app.pending_volume { + let api_vol = c.device.volume_percent.unwrap_or(0) as u8; + if api_vol == pending { + app.pending_volume = None; + app.last_dispatched_volume = None; + } else { + // API hasn't caught up yet — keep showing the user's intended value + if let Some(ctx) = app.current_playback_context.as_ref() { + c.device.volume_percent = ctx.device.volume_percent; + } + } + } + // On first load with native streaming AND native device is active, // override API shuffle with saved preference. #[cfg(feature = "streaming")] @@ -365,6 +381,14 @@ impl PlaybackNetwork for Network { return; } + // 404 = no active device/player; treat as idle, not an error + if err.to_string().contains("404") || err.to_string().contains("Not Found") { + app.current_playback_context = None; + app.instant_since_last_current_playback_poll = Instant::now(); + app.is_fetching_current_playback = false; + return; + } + app.handle_error(err); return; } @@ -708,6 +732,15 @@ impl PlaybackNetwork for Network { } } + /// Sends the volume change to Spotify, either through the native streaming + /// player or the Web API depending on which device is active. + /// + /// On success we clear the in-flight flag but keep `pending_volume` around. + /// It only gets cleared when `get_current_playback` comes back with a matching + /// volume — that's our signal that Spotify actually caught up. + /// + /// On error we bail and clear everything so the UI falls back to whatever + /// the API last reported. async fn change_volume(&mut self, volume: u8) { #[cfg(feature = "streaming")] if is_native_streaming_active_for_playback(self).await { @@ -717,6 +750,9 @@ impl PlaybackNetwork for Network { if let Some(ctx) = &mut app.current_playback_context { ctx.device.volume_percent = Some(volume.into()); } + app.is_volume_change_in_flight = false; + app.last_dispatched_volume = Some(volume); + // Keep pending_volume set — cleared when API confirms the value matches return; } } @@ -727,9 +763,15 @@ impl PlaybackNetwork for Network { if let Some(ctx) = &mut app.current_playback_context { ctx.device.volume_percent = Some(volume.into()); } + app.is_volume_change_in_flight = false; + app.last_dispatched_volume = Some(volume); + // Keep pending_volume set — cleared when get_current_playback confirms } Err(e) => { let mut app = self.app.lock().await; + app.is_volume_change_in_flight = false; + app.pending_volume = None; + app.last_dispatched_volume = None; app.handle_error(anyhow!(e)); } } diff --git a/src/infra/network/search.rs b/src/infra/network/search.rs index f56ee96d..6d9385f4 100644 --- a/src/infra/network/search.rs +++ b/src/infra/network/search.rs @@ -19,6 +19,7 @@ pub struct ArtistSearchResponse { pub trait SearchNetwork { async fn get_search_results(&mut self, search_term: String, country: Option); + async fn search_tracks_for_playlist(&mut self, search_term: String); } impl SearchNetwork for Network { @@ -151,4 +152,35 @@ impl SearchNetwork for Network { app.search_results.playlists = playlist_result; app.search_results.shows = show_result; } + + async fn search_tracks_for_playlist(&mut self, search_term: String) { + let result = self + .spotify + .search( + &search_term, + SearchType::Track, + None, + None, + Some(self.large_search_limit), + Some(0), + ) + .await; + + let tracks = match result { + Ok(SearchResult::Tracks(page)) => page + .items + .into_iter() + .filter_map(|t| if t.id.is_some() { Some(t) } else { None }) + .collect::>(), + Ok(_) => return, + Err(e) => { + self.handle_error(anyhow!(e)).await; + return; + } + }; + + let mut app = self.app.lock().await; + app.create_playlist_search_results = tracks; + app.create_playlist_selected_result = 0; + } } diff --git a/src/main.rs b/src/main.rs index f706ab5c..23081e44 100644 --- a/src/main.rs +++ b/src/main.rs @@ -133,7 +133,7 @@ struct DiscordPresenceState { last_progress_ms: u128, } -#[cfg(feature = "mpris")] +#[cfg(all(feature = "mpris", target_os = "linux"))] #[derive(Default, PartialEq)] struct MprisMetadata { title: String, @@ -142,7 +142,7 @@ struct MprisMetadata { duration_ms: u32, art_url: Option, } -#[cfg(feature = "mpris")] +#[cfg(all(feature = "mpris", target_os = "linux"))] type MprisMetadataTuple = (String, Vec, String, u32, Option); #[cfg(all(feature = "mpris", target_os = "linux"))] @@ -252,7 +252,7 @@ fn build_discord_playback(app: &App) -> Option { }) } -#[cfg(feature = "mpris")] +#[cfg(all(feature = "mpris", target_os = "linux"))] fn get_mpris_metadata(app: &App) -> Option { use crate::tui::ui::util::create_artist_string; use rspotify::model::PlayableItem; @@ -470,8 +470,15 @@ fn update_macos_metadata( // Manual token cache helpers since rspotify's built-in caching isn't working async fn save_token_to_file(spotify: &AuthCodePkceSpotify, path: &PathBuf) -> Result<()> { - let token_lock = spotify.token.lock().await.expect("Failed to lock token"); - if let Some(ref token) = *token_lock { + let mut token_lock = spotify.token.lock().await.expect("Failed to lock token"); + if let Some(ref mut token) = *token_lock { + if token.refresh_token.is_none() && path.exists() { + if let Ok(old_json) = fs::read_to_string(path) { + if let Ok(old_token) = serde_json::from_str::(&old_json) { + token.refresh_token = old_token.refresh_token; + } + } + } let token_json = serde_json::to_string_pretty(token)?; fs::write(path, token_json)?; info!("token cached to {}", path.display()); @@ -1139,14 +1146,35 @@ of the app. Beware that this comes at a CPU cost!", return Err(last_auth_error.unwrap_or_else(|| anyhow!("Authentication failed"))); }; + // Reconstruct the token cache path for the successfully authenticated client. + // client_config.client_id was updated in the loop to reflect the chosen client. + let final_token_cache_path = + token_cache_path_for_client(&config_paths.token_cache_path, &client_config.client_id); + + // Persist whatever token is now in memory. rspotify's auto_reauth (token_refreshing=true) + // silently refreshes the token during the spotify.me() probe in ensure_auth_token, rotating + // the refresh_token but never writing the result to disk (token_cached=false by default). + // Saving here ensures the on-disk token is always the current one, not the original stale one. + if let Err(e) = save_token_to_file(&spotify, &final_token_cache_path).await { + log::warn!("Failed to cache token on startup: {}", e); + } // Verify that we have a valid token before proceeding let token_lock = spotify.token.lock().await.expect("Failed to lock token"); let token_expiry = if let Some(ref token) = *token_lock { - // Convert TimeDelta to SystemTime - let expires_in_secs = token.expires_in.num_seconds() as u64; - SystemTime::now() - .checked_add(std::time::Duration::from_secs(expires_in_secs)) - .unwrap_or_else(SystemTime::now) + // Prefer expires_at (the absolute UTC timestamp stored in the token) so that + // app.spotify_token_expiry reflects the true remaining lifetime. Falling back + // to now + expires_in would always give ~60 min regardless of when the token + // was issued, causing refresh_authentication() to fire too late and letting + // rspotify's auto_reauth silently rotate the refresh_token without our save. + if let Some(expires_at) = token.expires_at { + let unix_secs = expires_at.timestamp().max(0) as u64; + SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(unix_secs) + } else { + let expires_in_secs = token.expires_in.num_seconds().max(0) as u64; + SystemTime::now() + .checked_add(std::time::Duration::from_secs(expires_in_secs)) + .unwrap_or_else(SystemTime::now) + } } else { drop(token_lock); return Err(anyhow!("Authentication failed: no valid token available")); @@ -1169,9 +1197,9 @@ of the app. Beware that this comes at a CPU cost!", // Save, because we checked if the subcommand is present at runtime let m = matches.subcommand_matches(cmd).unwrap(); #[cfg(feature = "streaming")] - let network = Network::new(spotify, client_config, &app); // CLI doesn't use streaming + let network = Network::new(spotify, client_config, &app, final_token_cache_path); // CLI doesn't use streaming #[cfg(not(feature = "streaming"))] - let network = Network::new(spotify, client_config, &app); + let network = Network::new(spotify, client_config, &app, final_token_cache_path); println!( "{}", cli::handle_matches(m, cmd.to_string(), network, user_config).await? @@ -1537,9 +1565,9 @@ of the app. Beware that this comes at a CPU cost!", info!("spawning spotify network event handler"); tokio::spawn(async move { #[cfg(feature = "streaming")] - let mut network = Network::new(spotify, client_config, &app); + let mut network = Network::new(spotify, client_config, &app, final_token_cache_path); #[cfg(not(feature = "streaming"))] - let mut network = Network::new(spotify, client_config, &app); + let mut network = Network::new(spotify, client_config, &app, final_token_cache_path); // Auto-select the saved playback device when available (fallback to native streaming). #[cfg(feature = "streaming")] @@ -1950,12 +1978,21 @@ async fn handle_player_events( } if let Ok(mut app) = app.try_lock() { - if let Some(ref mut ctx) = app.current_playback_context { - ctx.device.volume_percent = Some(volume_percent as u32); + if let Some(pending) = app.pending_volume { + if volume_percent == pending { + // Native player caught up — safe to clear pending + app.pending_volume = None; + app.last_dispatched_volume = None; + } + // If it doesn't match, the event is stale or from an external + // change — leave pending_volume alone so the UI stays correct. + } else { + if let Some(ref mut ctx) = app.current_playback_context { + ctx.device.volume_percent = Some(volume_percent as u32); + } + app.user_config.behavior.volume_percent = volume_percent.min(100); + let _ = app.user_config.save_config(); } - // Persist the latest volume so it is restored on next launch - app.user_config.behavior.volume_percent = volume_percent.min(100); - let _ = app.user_config.save_config(); } } PlayerEvent::PositionChanged { @@ -2544,6 +2581,10 @@ async fn start_ui( ActiveBlock::Settings => { ui::settings::draw_settings(f, &app); } + ActiveBlock::CreatePlaylistForm => { + ui::draw_main_layout(f, &app); + ui::draw_create_playlist_form(f, &app); + } _ => { ui::draw_main_layout(f, &app); } @@ -2636,7 +2677,9 @@ async fn start_ui( } event::Event::Mouse(mouse) => { let mut app = app.lock().await; - handlers::mouse_handler(mouse, &mut app); + if !app.user_config.behavior.disable_mouse_inputs { + handlers::mouse_handler(mouse, &mut app); + } } event::Event::Tick => { let mut app = app.lock().await; @@ -2646,6 +2689,7 @@ async fn start_ui( #[cfg(feature = "streaming")] app.flush_pending_native_seek(); app.flush_pending_api_seek(); + app.flush_pending_volume(); #[cfg(feature = "discord-rpc")] if let Some(ref manager) = discord_rpc_manager { @@ -2853,6 +2897,10 @@ async fn start_ui( ActiveBlock::AnnouncementPrompt => ui::draw_announcement_prompt(f, &app), ActiveBlock::ExitPrompt => ui::draw_exit_prompt(f, &app), ActiveBlock::Settings => ui::settings::draw_settings(f, &app), + ActiveBlock::CreatePlaylistForm => { + ui::draw_main_layout(f, &app); + ui::draw_create_playlist_form(f, &app); + } _ => ui::draw_main_layout(f, &app), } })?; @@ -2937,7 +2985,9 @@ async fn start_ui( } event::Event::Mouse(mouse) => { let mut app = app.lock().await; - handlers::mouse_handler(mouse, &mut app); + if !app.user_config.behavior.disable_mouse_inputs { + handlers::mouse_handler(mouse, &mut app); + } } event::Event::Tick => { // Tick the main run loop so macOS delivers media key events. @@ -2955,6 +3005,7 @@ async fn start_ui( #[cfg(feature = "streaming")] app.flush_pending_native_seek(); app.flush_pending_api_seek(); + app.flush_pending_volume(); #[cfg(feature = "discord-rpc")] if let Some(ref manager) = discord_rpc_manager { @@ -3042,3 +3093,246 @@ async fn start_ui( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeDelta, Utc}; + + fn create_test_token(refresh_token: Option) -> Token { + Token { + access_token: "test_access_token".to_string(), + refresh_token, + expires_in: TimeDelta::seconds(3600), + expires_at: Some(Utc::now() + TimeDelta::seconds(3600)), + scopes: Default::default(), + } + } + + async fn create_test_spotify(token: Token) -> AuthCodePkceSpotify { + let creds = Credentials::new("test_client_id", "test_client_secret"); + let oauth = OAuth { + redirect_uri: "http://localhost:8888/callback".to_string(), + scopes: Default::default(), + ..Default::default() + }; + let config = Config::default(); + let spotify = AuthCodePkceSpotify::with_config(creds, oauth, config); + + let mut token_lock = spotify.token.lock().await.expect("Failed to lock token"); + *token_lock = Some(token); + drop(token_lock); + + spotify + } + + fn create_temp_path() -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!( + "spotatui_test_token_{}.json", + rand::random::() + )); + path + } + + #[tokio::test] + async fn test_save_token_preserves_refresh_token_when_missing() { + let path = create_temp_path(); + + // First, save a token with a refresh token + let initial_token = create_test_token(Some("initial_refresh_token".to_string())); + let spotify1 = create_test_spotify(initial_token).await; + save_token_to_file(&spotify1, &path).await.unwrap(); + + // Now save a token without a refresh token (simulating a token refresh) + let refreshed_token = create_test_token(None); + let spotify2 = create_test_spotify(refreshed_token).await; + save_token_to_file(&spotify2, &path).await.unwrap(); + + // Read back and verify refresh token was preserved + let saved_json = fs::read_to_string(&path).unwrap(); + let saved_token: Token = serde_json::from_str(&saved_json).unwrap(); + assert_eq!( + saved_token.refresh_token, + Some("initial_refresh_token".to_string()) + ); + assert_eq!(saved_token.access_token, "test_access_token"); + + // Cleanup + let _ = fs::remove_file(&path); + } + + #[tokio::test] + async fn test_save_token_uses_new_refresh_token_when_present() { + let path = create_temp_path(); + + // First, save a token with a refresh token + let initial_token = create_test_token(Some("initial_refresh_token".to_string())); + let spotify1 = create_test_spotify(initial_token).await; + save_token_to_file(&spotify1, &path).await.unwrap(); + + // Now save a token with a new refresh token + let new_token = create_test_token(Some("new_refresh_token".to_string())); + let spotify2 = create_test_spotify(new_token).await; + save_token_to_file(&spotify2, &path).await.unwrap(); + + // Read back and verify new refresh token was used + let saved_json = fs::read_to_string(&path).unwrap(); + let saved_token: Token = serde_json::from_str(&saved_json).unwrap(); + assert_eq!( + saved_token.refresh_token, + Some("new_refresh_token".to_string()) + ); + + // Cleanup + let _ = fs::remove_file(&path); + } + + #[tokio::test] + async fn test_save_token_works_without_existing_file() { + let path = create_temp_path(); + + // Save a token without a refresh token to a non-existent file + let token = create_test_token(None); + let spotify = create_test_spotify(token).await; + save_token_to_file(&spotify, &path).await.unwrap(); + + // Verify it saved successfully + let saved_json = fs::read_to_string(&path).unwrap(); + let saved_token: Token = serde_json::from_str(&saved_json).unwrap(); + assert_eq!(saved_token.refresh_token, None); + assert_eq!(saved_token.access_token, "test_access_token"); + + // Cleanup + let _ = fs::remove_file(&path); + } + + #[tokio::test] + async fn test_expired_token_detection_with_refresh_token() { + // Create an expired token with refresh_token + let expired_token = Token { + access_token: "expired_access_token".to_string(), + refresh_token: Some("valid_refresh_token".to_string()), + expires_in: TimeDelta::seconds(3600), + expires_at: Some(Utc::now() - TimeDelta::seconds(3600)), // 1 hour ago + scopes: Default::default(), + }; + + let spotify = create_test_spotify(expired_token).await; + + // Check if token needs refresh (mimics the logic in ensure_auth_token) + let should_refresh = { + let token_lock = spotify.token.lock().await.expect("Failed to lock token"); + if let Some(ref token) = *token_lock { + token + .expires_at + .map(|exp| exp < Utc::now()) + .unwrap_or(false) + && token.refresh_token.is_some() + } else { + false + } + }; + + assert!( + should_refresh, + "Expired token with refresh_token should be detected as needing refresh" + ); + } + + #[tokio::test] + async fn test_expired_token_without_refresh_token_not_refreshable() { + // Create an expired token WITHOUT refresh_token + let expired_token = Token { + access_token: "expired_access_token".to_string(), + refresh_token: None, + expires_in: TimeDelta::seconds(3600), + expires_at: Some(Utc::now() - TimeDelta::seconds(3600)), + scopes: Default::default(), + }; + + let spotify = create_test_spotify(expired_token).await; + + let should_refresh = { + let token_lock = spotify.token.lock().await.expect("Failed to lock token"); + if let Some(ref token) = *token_lock { + token + .expires_at + .map(|exp| exp < Utc::now()) + .unwrap_or(false) + && token.refresh_token.is_some() + } else { + false + } + }; + + assert!( + !should_refresh, + "Expired token without refresh_token should NOT be refreshable" + ); + } + + #[tokio::test] + async fn test_valid_token_does_not_need_refresh() { + // Create a valid (non-expired) token + let valid_token = Token { + access_token: "valid_access_token".to_string(), + refresh_token: Some("refresh_token".to_string()), + expires_in: TimeDelta::seconds(3600), + expires_at: Some(Utc::now() + TimeDelta::seconds(3600)), // 1 hour in future + scopes: Default::default(), + }; + + let spotify = create_test_spotify(valid_token).await; + + let should_refresh = { + let token_lock = spotify.token.lock().await.expect("Failed to lock token"); + if let Some(ref token) = *token_lock { + token + .expires_at + .map(|exp| exp < Utc::now()) + .unwrap_or(false) + && token.refresh_token.is_some() + } else { + false + } + }; + + assert!( + !should_refresh, + "Valid non-expired token should not need refresh" + ); + } + + #[tokio::test] + async fn test_token_without_expires_at_does_not_need_refresh() { + // Create a token with no expires_at field + let token = Token { + access_token: "access_token".to_string(), + refresh_token: Some("refresh_token".to_string()), + expires_in: TimeDelta::seconds(3600), + expires_at: None, + scopes: Default::default(), + }; + + let spotify = create_test_spotify(token).await; + + let should_refresh = { + let token_lock = spotify.token.lock().await.expect("Failed to lock token"); + if let Some(ref token) = *token_lock { + token + .expires_at + .map(|exp| exp < Utc::now()) + .unwrap_or(false) + && token.refresh_token.is_some() + } else { + false + } + }; + + assert!( + !should_refresh, + "Token without expires_at should not trigger refresh" + ); + } +} diff --git a/src/tui/cover_art.rs b/src/tui/cover_art.rs index 01f8f0e4..790d526c 100644 --- a/src/tui/cover_art.rs +++ b/src/tui/cover_art.rs @@ -117,6 +117,10 @@ impl CoverArt { Self::render_state(&self.fullscreen_state, f, area); } + pub fn fullscreen_size_for(&self, area: Rect) -> Option { + Self::size_for_state(&self.fullscreen_state, area) + } + fn render_state(state: &Mutex>, f: &mut Frame, area: Rect) { let mut lock = state.lock().unwrap(); if let Some(sp) = lock.as_mut() { @@ -127,4 +131,11 @@ impl CoverArt { ); } } + + fn size_for_state(state: &Mutex>, area: Rect) -> Option { + let lock = state.lock().unwrap(); + lock + .as_ref() + .map(|sp| sp.image.size_for(Resize::Fit(None), area)) + } } diff --git a/src/tui/event/events.rs b/src/tui/event/events.rs index f1fa1467..9bbf355c 100644 --- a/src/tui/event/events.rs +++ b/src/tui/event/events.rs @@ -58,26 +58,23 @@ impl Events { // poll for tick rate duration, if no event, sent tick event. if event::poll(config.tick_rate).unwrap() { match event::read().unwrap() { - CrosstermEvent::Key(key) => { - // Only process key press events, not release or repeat. - // This fixes duplicate key events on Windows where both - // Press and Release events are sent for each key press. - if key.kind == KeyEventKind::Press { - let key = Key::from(key); - // If send fails, the receiver has been dropped (app is closing) - if event_tx.send(Event::Input(key)).is_err() { - break; - } + // Only process key press events, not release or repeat. + // This fixes duplicate key events on Windows where both + // Press and Release events are sent for each key press. + CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => { + let key = Key::from(key); + // If send fails, the receiver has been dropped (app is closing) + if event_tx.send(Event::Input(key)).is_err() { + break; } } - CrosstermEvent::Mouse(mouse) => { + CrosstermEvent::Mouse(mouse) if matches!( mouse.kind, MouseEventKind::Down(_) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) && event_tx.send(Event::Mouse(mouse)).is_err() - { - break; - } + ) && event_tx.send(Event::Mouse(mouse)).is_err() => + { + break; } _ => {} } diff --git a/src/tui/handlers/artist.rs b/src/tui/handlers/artist.rs index 83329cd7..8f7148a1 100644 --- a/src/tui/handlers/artist.rs +++ b/src/tui/handlers/artist.rs @@ -279,20 +279,20 @@ pub fn handler(key: Key, app: &mut App) { artist.artist_selected_block = ArtistBlock::Empty; handle_down_press_on_hovered_block(app); } - k if common_key_events::high_event(k) => { - if artist.artist_selected_block != ArtistBlock::Empty { - handle_high_press_on_selected_block(app); - } + k if common_key_events::high_event(k) + && artist.artist_selected_block != ArtistBlock::Empty => + { + handle_high_press_on_selected_block(app); } - k if common_key_events::middle_event(k) => { - if artist.artist_selected_block != ArtistBlock::Empty { - handle_middle_press_on_selected_block(app); - } + k if common_key_events::middle_event(k) + && artist.artist_selected_block != ArtistBlock::Empty => + { + handle_middle_press_on_selected_block(app); } - k if common_key_events::low_event(k) => { - if artist.artist_selected_block != ArtistBlock::Empty { - handle_low_press_on_selected_block(app); - } + k if common_key_events::low_event(k) + && artist.artist_selected_block != ArtistBlock::Empty => + { + handle_low_press_on_selected_block(app); } Key::Enter => { if artist.artist_selected_block != ArtistBlock::Empty { @@ -301,10 +301,8 @@ pub fn handler(key: Key, app: &mut App) { handle_enter_event_on_hovered_block(app); } } - Key::Char('r') => { - if artist.artist_selected_block != ArtistBlock::Empty { - handle_recommend_event_on_selected_block(app); - } + Key::Char('r') if artist.artist_selected_block != ArtistBlock::Empty => { + handle_recommend_event_on_selected_block(app); } Key::Char('w') => match artist.artist_selected_block { ArtistBlock::TopTracks => open_add_to_playlist_for_selected_top_track(app), diff --git a/src/tui/handlers/artists.rs b/src/tui/handlers/artists.rs index dcaa35eb..e0df040a 100644 --- a/src/tui/handlers/artists.rs +++ b/src/tui/handlers/artists.rs @@ -40,36 +40,36 @@ pub fn handler(key: Key, app: &mut App) { } } Key::Enter => { - let artists = app.artists.to_owned(); - if !artists.is_empty() { - let artist = &artists[app.artists_list_index]; - app.get_artist(artist.id.as_ref().into_static(), artist.name.clone()); + if let Some(artists) = app.library.saved_artists.get_results(None) { + if let Some(artist) = artists.items.get(app.artists_list_index) { + app.get_artist(artist.id.as_ref().into_static(), artist.name.clone()); + } } } Key::Char('D') => app.user_unfollow_artists(ActiveBlock::AlbumList), Key::Char('e') => { - let artists = app.artists.to_owned(); - let artist = artists.get(app.artists_list_index); - if let Some(artist) = artist { - app.dispatch(IoEvent::StartPlayback( - Some(rspotify::model::PlayContextId::Artist( - artist.id.clone().into_static(), - )), - None, - None, - )); + if let Some(artists) = app.library.saved_artists.get_results(None) { + if let Some(artist) = artists.items.get(app.artists_list_index) { + app.dispatch(IoEvent::StartPlayback( + Some(rspotify::model::PlayContextId::Artist( + artist.id.clone().into_static(), + )), + None, + None, + )); + } } } Key::Char('r') => { - let artists = app.artists.to_owned(); - let artist = artists.get(app.artists_list_index); - if let Some(artist) = artist { - let artist_name = artist.name.clone(); - let artist_id_list: Option> = Some(vec![artist.id.id().to_string()]); + if let Some(artists) = app.library.saved_artists.get_results(None) { + if let Some(artist) = artists.items.get(app.artists_list_index) { + let artist_name = artist.name.clone(); + let artist_id_list: Option> = Some(vec![artist.id.id().to_string()]); - app.recommendations_context = Some(RecommendationsContext::Artist); - app.recommendations_seed = artist_name; - app.get_recommendations_for_seed(artist_id_list, None, None); + app.recommendations_context = Some(RecommendationsContext::Artist); + app.recommendations_seed = artist_name; + app.get_recommendations_for_seed(artist_id_list, None, None); + } } } k if k == app.user_config.keys.next_page => app.get_current_user_saved_artists_next(), diff --git a/src/tui/handlers/common_key_events.rs b/src/tui/handlers/common_key_events.rs index 40c50a8b..186b6809 100644 --- a/src/tui/handlers/common_key_events.rs +++ b/src/tui/handlers/common_key_events.rs @@ -146,6 +146,7 @@ pub fn handle_right_event(app: &mut App) { RouteId::HelpMenu => {} RouteId::Queue => {} RouteId::Party => {} + RouteId::CreatePlaylist => {} }, _ => {} }; diff --git a/src/tui/handlers/create_playlist.rs b/src/tui/handlers/create_playlist.rs new file mode 100644 index 00000000..14275912 --- /dev/null +++ b/src/tui/handlers/create_playlist.rs @@ -0,0 +1,210 @@ +use crate::core::app::{App, CreatePlaylistFocus, CreatePlaylistStage}; +use crate::infra::network::IoEvent; +use crate::tui::event::Key; +use unicode_width::UnicodeWidthChar; + +pub fn handler(key: Key, app: &mut App) { + match app.create_playlist_stage { + CreatePlaylistStage::Name => handle_name_stage(key, app), + CreatePlaylistStage::AddTracks => handle_add_tracks_stage(key, app), + } +} + +fn handle_name_stage(key: Key, app: &mut App) { + match key { + Key::Enter => { + let name: String = app.create_playlist_name.iter().collect(); + if !name.trim().is_empty() { + app.create_playlist_stage = CreatePlaylistStage::AddTracks; + app.create_playlist_focus = CreatePlaylistFocus::SearchInput; + } + } + Key::Esc => { + close_form(app); + } + Key::Backspace if app.create_playlist_name_idx > 0 => { + app.create_playlist_name_idx -= 1; + let removed = app + .create_playlist_name + .remove(app.create_playlist_name_idx); + let width = removed.width().unwrap_or(1) as u16; + app.create_playlist_name_cursor = app.create_playlist_name_cursor.saturating_sub(width); + } + Key::Char(c) => { + app + .create_playlist_name + .insert(app.create_playlist_name_idx, c); + app.create_playlist_name_idx += 1; + app.create_playlist_name_cursor += c.width().unwrap_or(1) as u16; + } + Key::Left if app.create_playlist_name_idx > 0 => { + app.create_playlist_name_idx -= 1; + let c = app.create_playlist_name[app.create_playlist_name_idx]; + app.create_playlist_name_cursor = app + .create_playlist_name_cursor + .saturating_sub(c.width().unwrap_or(1) as u16); + } + Key::Right if app.create_playlist_name_idx < app.create_playlist_name.len() => { + let c = app.create_playlist_name[app.create_playlist_name_idx]; + app.create_playlist_name_idx += 1; + app.create_playlist_name_cursor += c.width().unwrap_or(1) as u16; + } + _ => {} + } +} + +fn handle_add_tracks_stage(key: Key, app: &mut App) { + match app.create_playlist_focus { + CreatePlaylistFocus::SearchInput => handle_search_input(key, app), + CreatePlaylistFocus::SearchResults => handle_results_nav(key, app), + CreatePlaylistFocus::AddedTracks => handle_added_tracks_nav(key, app), + } +} + +fn handle_search_input(key: Key, app: &mut App) { + match key { + Key::Esc => { + close_form(app); + } + Key::Enter => { + let query: String = app.create_playlist_search_input.iter().collect(); + if !query.trim().is_empty() { + app.dispatch(IoEvent::SearchTracksForPlaylist(query)); + app.create_playlist_focus = CreatePlaylistFocus::SearchResults; + } + } + Key::Tab => { + if !app.create_playlist_tracks.is_empty() { + app.create_playlist_selected_result = 0; + app.create_playlist_focus = CreatePlaylistFocus::AddedTracks; + } else if !app.create_playlist_search_results.is_empty() { + app.create_playlist_selected_result = 0; + app.create_playlist_focus = CreatePlaylistFocus::SearchResults; + } + } + Key::Down if !app.create_playlist_search_results.is_empty() => { + app.create_playlist_selected_result = 0; + app.create_playlist_focus = CreatePlaylistFocus::SearchResults; + } + Key::Backspace if app.create_playlist_search_idx > 0 => { + app.create_playlist_search_idx -= 1; + let removed = app + .create_playlist_search_input + .remove(app.create_playlist_search_idx); + let width = removed.width().unwrap_or(1) as u16; + app.create_playlist_search_cursor = app.create_playlist_search_cursor.saturating_sub(width); + } + Key::Char(c) => { + app + .create_playlist_search_input + .insert(app.create_playlist_search_idx, c); + app.create_playlist_search_idx += 1; + app.create_playlist_search_cursor += c.width().unwrap_or(1) as u16; + } + Key::Left if app.create_playlist_search_idx > 0 => { + app.create_playlist_search_idx -= 1; + let c = app.create_playlist_search_input[app.create_playlist_search_idx]; + app.create_playlist_search_cursor = app + .create_playlist_search_cursor + .saturating_sub(c.width().unwrap_or(1) as u16); + } + Key::Right if app.create_playlist_search_idx < app.create_playlist_search_input.len() => { + let c = app.create_playlist_search_input[app.create_playlist_search_idx]; + app.create_playlist_search_idx += 1; + app.create_playlist_search_cursor += c.width().unwrap_or(1) as u16; + } + _ => {} + } +} + +fn handle_results_nav(key: Key, app: &mut App) { + let count = app.create_playlist_search_results.len(); + match key { + Key::Esc => { + app.create_playlist_focus = CreatePlaylistFocus::SearchInput; + } + Key::Up if count > 0 && app.create_playlist_selected_result > 0 => { + app.create_playlist_selected_result -= 1; + } + Key::Down if count > 0 && app.create_playlist_selected_result + 1 < count => { + app.create_playlist_selected_result += 1; + } + Key::Enter if count > 0 => { + let idx = app.create_playlist_selected_result; + if idx < count { + let track = app.create_playlist_search_results[idx].clone(); + app.create_playlist_tracks.push(track); + } + } + Key::Tab => { + if !app.create_playlist_tracks.is_empty() { + app.create_playlist_selected_result = 0; + app.create_playlist_focus = CreatePlaylistFocus::AddedTracks; + } else { + app.create_playlist_focus = CreatePlaylistFocus::SearchInput; + } + } + _ => {} + } +} + +fn handle_added_tracks_nav(key: Key, app: &mut App) { + let count = app.create_playlist_tracks.len(); + match key { + Key::Esc => { + app.create_playlist_focus = CreatePlaylistFocus::SearchInput; + } + Key::Tab => { + app.create_playlist_focus = CreatePlaylistFocus::SearchInput; + } + Key::Up if count > 0 && app.create_playlist_selected_result > 0 => { + app.create_playlist_selected_result -= 1; + } + Key::Down if count > 0 && app.create_playlist_selected_result + 1 < count => { + app.create_playlist_selected_result += 1; + } + Key::Char('d') if count > 0 => { + let idx = app.create_playlist_selected_result; + if idx < count { + app.create_playlist_tracks.remove(idx); + if app.create_playlist_selected_result >= app.create_playlist_tracks.len() + && !app.create_playlist_tracks.is_empty() + { + app.create_playlist_selected_result = app.create_playlist_tracks.len() - 1; + } + } + } + Key::Enter => { + submit_playlist(app); + } + _ => {} + } +} + +fn submit_playlist(app: &mut App) { + let name: String = app.create_playlist_name.iter().collect(); + let track_ids: Vec> = app + .create_playlist_tracks + .iter() + .filter_map(|t| t.id.clone()) + .collect(); + + app.dispatch(IoEvent::CreateNewPlaylist(name, track_ids)); + close_form(app); +} + +fn close_form(app: &mut App) { + app.pop_navigation_stack(); + // Reset form state + app.create_playlist_name = Vec::new(); + app.create_playlist_name_idx = 0; + app.create_playlist_name_cursor = 0; + app.create_playlist_stage = CreatePlaylistStage::Name; + app.create_playlist_tracks = Vec::new(); + app.create_playlist_search_results = Vec::new(); + app.create_playlist_search_input = Vec::new(); + app.create_playlist_search_idx = 0; + app.create_playlist_search_cursor = 0; + app.create_playlist_selected_result = 0; + app.create_playlist_focus = CreatePlaylistFocus::SearchInput; +} diff --git a/src/tui/handlers/dialog.rs b/src/tui/handlers/dialog.rs index f5241987..22e9cd68 100644 --- a/src/tui/handlers/dialog.rs +++ b/src/tui/handlers/dialog.rs @@ -56,40 +56,30 @@ fn handle_add_to_playlist_picker(key: Key, app: &mut App) { let editable_playlists = app.editable_playlists(); let playlist_count = editable_playlists.len(); match key { - k if common_key_events::down_event(k) => { - if playlist_count > 0 { - let next = common_key_events::on_down_press_handler( - &editable_playlists, - Some(app.playlist_picker_selected_index), - ); - app.playlist_picker_selected_index = next; - } + k if common_key_events::down_event(k) && playlist_count > 0 => { + let next = common_key_events::on_down_press_handler( + &editable_playlists, + Some(app.playlist_picker_selected_index), + ); + app.playlist_picker_selected_index = next; } - k if common_key_events::up_event(k) => { - if playlist_count > 0 { - let next = common_key_events::on_up_press_handler( - &editable_playlists, - Some(app.playlist_picker_selected_index), - ); - app.playlist_picker_selected_index = next; - } + k if common_key_events::up_event(k) && playlist_count > 0 => { + let next = common_key_events::on_up_press_handler( + &editable_playlists, + Some(app.playlist_picker_selected_index), + ); + app.playlist_picker_selected_index = next; } - k if common_key_events::high_event(k) => { - if playlist_count > 0 { - app.playlist_picker_selected_index = common_key_events::on_high_press_handler(); - } + k if common_key_events::high_event(k) && playlist_count > 0 => { + app.playlist_picker_selected_index = common_key_events::on_high_press_handler(); } - k if common_key_events::middle_event(k) => { - if playlist_count > 0 { - app.playlist_picker_selected_index = - common_key_events::on_middle_press_handler(&editable_playlists); - } + k if common_key_events::middle_event(k) && playlist_count > 0 => { + app.playlist_picker_selected_index = + common_key_events::on_middle_press_handler(&editable_playlists); } - k if common_key_events::low_event(k) => { - if playlist_count > 0 { - app.playlist_picker_selected_index = - common_key_events::on_low_press_handler(&editable_playlists); - } + k if common_key_events::low_event(k) && playlist_count > 0 => { + app.playlist_picker_selected_index = + common_key_events::on_low_press_handler(&editable_playlists); } Key::Enter => { if let Some(pending_add) = app.pending_playlist_track_add.clone() { diff --git a/src/tui/handlers/discover.rs b/src/tui/handlers/discover.rs index be08b1fb..290e42db 100644 --- a/src/tui/handlers/discover.rs +++ b/src/tui/handlers/discover.rs @@ -26,25 +26,19 @@ pub fn handler(key: Key, app: &mut App) { app.discover_selected_index = next_index; } // Left/Right to cycle time range (for Top Tracks) - k if common_key_events::right_event(k) => { - if app.discover_selected_index == 1 { - // Only cycle time range when Top Tracks is selected - app.discover_time_range = app.discover_time_range.next(); - // Clear cache so it refetches with new time range - app.discover_top_tracks.clear(); - } + k if common_key_events::right_event(k) && app.discover_selected_index == 1 => { + // Only cycle time range when Top Tracks is selected + app.discover_time_range = app.discover_time_range.next(); + // Clear cache so it refetches with new time range + app.discover_top_tracks.clear(); } - Key::Char('[') => { - if app.discover_selected_index == 1 { - app.discover_time_range = app.discover_time_range.prev(); - app.discover_top_tracks.clear(); - } + Key::Char('[') if app.discover_selected_index == 1 => { + app.discover_time_range = app.discover_time_range.prev(); + app.discover_top_tracks.clear(); } - Key::Char(']') => { - if app.discover_selected_index == 1 { - app.discover_time_range = app.discover_time_range.next(); - app.discover_top_tracks.clear(); - } + Key::Char(']') if app.discover_selected_index == 1 => { + app.discover_time_range = app.discover_time_range.next(); + app.discover_top_tracks.clear(); } Key::Enter => { if app.discover_loading { diff --git a/src/tui/handlers/empty.rs b/src/tui/handlers/empty.rs index 5fbceead..e0be6192 100644 --- a/src/tui/handlers/empty.rs +++ b/src/tui/handlers/empty.rs @@ -55,10 +55,8 @@ pub fn handler(key: Key, app: &mut App) { _ => {} }, k if common_key_events::right_event(k) => common_key_events::handle_right_event(app), - Key::Char('w') => { - if app.get_current_route().hovered_block == ActiveBlock::PlayBar { - super::playbar::handler(key, app); - } + Key::Char('w') if app.get_current_route().hovered_block == ActiveBlock::PlayBar => { + super::playbar::handler(key, app); } _ => (), }; diff --git a/src/tui/handlers/home.rs b/src/tui/handlers/home.rs index f5950762..12276299 100644 --- a/src/tui/handlers/home.rs +++ b/src/tui/handlers/home.rs @@ -11,10 +11,8 @@ pub fn handler(key: Key, app: &mut App) { k if common_key_events::down_event(k) => { app.home_scroll += SMALL_SCROLL; } - k if common_key_events::up_event(k) => { - if app.home_scroll > 0 { - app.home_scroll -= SMALL_SCROLL; - } + k if common_key_events::up_event(k) && app.home_scroll > 0 => { + app.home_scroll -= SMALL_SCROLL; } k if k == app.user_config.keys.next_page => { app.home_scroll += LARGE_SCROLL; diff --git a/src/tui/handlers/input.rs b/src/tui/handlers/input.rs index 6bfb869c..04ceaab2 100644 --- a/src/tui/handlers/input.rs +++ b/src/tui/handlers/input.rs @@ -52,19 +52,15 @@ pub fn handler(key: Key, app: &mut App) { app.input_idx = 0; app.input_cursor_position = 0; } - Key::Left | Key::Ctrl('b') => { - if !app.input.is_empty() && app.input_idx > 0 { - let last_c = app.input[app.input_idx - 1]; - app.input_idx -= 1; - app.input_cursor_position -= compute_character_width(last_c); - } + Key::Left | Key::Ctrl('b') if !app.input.is_empty() && app.input_idx > 0 => { + let last_c = app.input[app.input_idx - 1]; + app.input_idx -= 1; + app.input_cursor_position -= compute_character_width(last_c); } - Key::Right | Key::Ctrl('f') => { - if app.input_idx < app.input.len() { - let next_c = app.input[app.input_idx]; - app.input_idx += 1; - app.input_cursor_position += compute_character_width(next_c); - } + Key::Right | Key::Ctrl('f') if app.input_idx < app.input.len() => { + let next_c = app.input[app.input_idx]; + app.input_idx += 1; + app.input_cursor_position += compute_character_width(next_c); } Key::Esc => { app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); @@ -79,17 +75,13 @@ pub fn handler(key: Key, app: &mut App) { app.input_idx += 1; app.input_cursor_position += compute_character_width(c); } - Key::Backspace | Key::Ctrl('h') => { - if !app.input.is_empty() && app.input_idx > 0 { - let last_c = app.input.remove(app.input_idx - 1); - app.input_idx -= 1; - app.input_cursor_position -= compute_character_width(last_c); - } + Key::Backspace | Key::Ctrl('h') if !app.input.is_empty() && app.input_idx > 0 => { + let last_c = app.input.remove(app.input_idx - 1); + app.input_idx -= 1; + app.input_cursor_position -= compute_character_width(last_c); } - Key::Delete | Key::Ctrl('d') => { - if !app.input.is_empty() && app.input_idx < app.input.len() { - app.input.remove(app.input_idx); - } + Key::Delete | Key::Ctrl('d') if !app.input.is_empty() && app.input_idx < app.input.len() => { + app.input.remove(app.input_idx); } _ => {} } diff --git a/src/tui/handlers/mod.rs b/src/tui/handlers/mod.rs index 2cec33ab..5dd43fee 100644 --- a/src/tui/handlers/mod.rs +++ b/src/tui/handlers/mod.rs @@ -7,6 +7,7 @@ mod artists; mod common_key_events; #[cfg(feature = "cover-art")] mod cover_art_view; +mod create_playlist; mod dialog; mod discover; mod empty; @@ -75,6 +76,13 @@ pub fn handle_app(key: Key, app: &mut App) { return; } + // When Create Playlist form is open, all keys go directly to the form handler + // (so typed characters aren't stolen by global bindings like 'd', space, etc.) + if app.get_current_route().active_block == ActiveBlock::CreatePlaylistForm { + handle_block_events(key, app); + return; + } + if app.maybe_activate_open_settings_fallback(key) { open_settings(app); if app.pending_keybinding_persist.is_some() { @@ -176,6 +184,13 @@ pub fn handle_app(key: Key, app: &mut App) { _ if key == app.user_config.keys.listening_party => { app.push_navigation_stack(RouteId::Party, ActiveBlock::Party); } + _ if key == app.user_config.keys.like_track => { + if is_input_mode(app) { + handle_block_events(key, app); + } else { + playbar::toggle_like_currently_playing_item(app); + } + } // Resize sidebar: { decreases, } increases width Key::Char('{') => { if is_input_mode(app) { @@ -239,6 +254,7 @@ fn is_input_mode(app: &App) -> bool { | ActiveBlock::Dialog(_) | ActiveBlock::AnnouncementPrompt | ActiveBlock::ExitPrompt + | ActiveBlock::CreatePlaylistForm ) } @@ -333,6 +349,9 @@ fn handle_block_events(key: Key, app: &mut App) { ActiveBlock::Party => { party::handler(key, app); } + ActiveBlock::CreatePlaylistForm => { + create_playlist::handler(key, app); + } } } @@ -380,6 +399,9 @@ fn handle_escape(app: &mut App) { app.sort_context = None; app.set_current_route_state(Some(ActiveBlock::Empty), None); } + ActiveBlock::CreatePlaylistForm => { + create_playlist::handler(Key::Esc, app); + } _ => { app.set_current_route_state(Some(ActiveBlock::Empty), None); } @@ -447,6 +469,47 @@ mod tests { use super::*; #[cfg(target_os = "macos")] use crate::core::app::TrackTableContext; + use crate::core::user_config::UserConfig; + use chrono::{Duration as ChronoDuration, Utc}; + use rspotify::model::{ + artist::SimplifiedArtist, + context::{Actions, CurrentPlaybackContext}, + enums::{DeviceType, RepeatState}, + idtypes::TrackId, + CurrentlyPlayingType, Device, FullTrack, PlayableId, PlayableItem, SimplifiedAlbum, + }; + use std::{collections::HashMap, sync::mpsc::channel, time::SystemTime}; + + #[allow(deprecated)] + fn full_track(id: &str, name: &str) -> FullTrack { + FullTrack { + album: SimplifiedAlbum { + name: "Album".to_string(), + ..Default::default() + }, + artists: vec![SimplifiedArtist { + name: "Artist".to_string(), + ..Default::default() + }], + available_markets: Vec::new(), + disc_number: 1, + duration: ChronoDuration::milliseconds(180_000), + explicit: false, + external_ids: HashMap::new(), + external_urls: HashMap::new(), + href: None, + id: Some(TrackId::from_id(id).unwrap().into_static()), + is_local: false, + is_playable: Some(true), + linked_from: None, + restrictions: None, + name: name.to_string(), + popularity: 50, + preview_url: None, + track_number: 1, + r#type: rspotify::model::Type::Track, + } + } #[test] fn global_shift_w_adds_current_track_from_anywhere() { @@ -485,6 +548,57 @@ mod tests { assert_eq!(app.get_current_route().active_block, ActiveBlock::Empty); } + #[test] + fn global_shift_f_likes_current_track_from_anywhere() { + let (tx, rx) = channel(); + let mut app = App::new(tx, UserConfig::new(), SystemTime::now()); + let track = full_track("0000000000000000000001", "Track 1"); + let expected_track_id = track.id.clone().unwrap(); + + 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.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); + + // Default like_track is Key::Char('F') + handle_app(Key::Char('F'), &mut app); + + match rx.recv().unwrap() { + IoEvent::ToggleSaveTrack(PlayableId::Track(track_id)) => { + assert_eq!(track_id, expected_track_id); + } + _ => panic!("unexpected event"), + } + } + + #[test] + fn global_shift_f_is_not_intercepted_in_input_mode() { + let mut app = App::default(); + app.set_current_route_state(Some(ActiveBlock::Input), Some(ActiveBlock::Input)); + + handle_app(Key::Char('F'), &mut app); + + // In input mode, 'F' should be added to the input buffer + assert_eq!(app.input, vec!['F']); + } + #[cfg(target_os = "macos")] #[test] fn plain_comma_fallback_opens_settings_and_prompts_to_persist() { diff --git a/src/tui/handlers/mouse.rs b/src/tui/handlers/mouse.rs index ce9771e9..d66662c3 100644 --- a/src/tui/handlers/mouse.rs +++ b/src/tui/handlers/mouse.rs @@ -2,12 +2,12 @@ use super::{library, playbar, playlist, settings, track_table}; use crate::core::app::{ ActiveBlock, App, RouteId, SettingValue, SettingsCategory, LIBRARY_OPTIONS, }; -use crate::core::layout::{library_constraints, playbar_constraint, sidebar_constraints}; +use crate::core::layout::{ + fullscreen_view_layout, library_constraints, playbar_constraint, sidebar_constraints, +}; use crate::tui::event::Key; use crate::tui::ui::player::playbar_control_at; -use crate::tui::ui::util::{ - get_main_layout_margin, FULLSCREEN_VIEW_PLAYBAR_HEIGHT, SMALL_TERMINAL_WIDTH, -}; +use crate::tui::ui::util::{get_main_layout_margin, SMALL_TERMINAL_WIDTH}; use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::{Constraint, Layout, Rect}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; @@ -253,15 +253,11 @@ fn handle_settings_tabs_mouse(mouse: MouseEvent, tabs_area: Rect, app: &mut App) fn handle_settings_list_mouse(mouse: MouseEvent, list_area: Rect, app: &mut App) { match mouse.kind { - MouseEventKind::ScrollDown => { - if !selected_setting_expects_key_capture(app) { - settings::handler(Key::Down, app); - } + MouseEventKind::ScrollDown if !selected_setting_expects_key_capture(app) => { + settings::handler(Key::Down, app); } - MouseEventKind::ScrollUp => { - if !selected_setting_expects_key_capture(app) { - settings::handler(Key::Up, app); - } + MouseEventKind::ScrollUp if !selected_setting_expects_key_capture(app) => { + settings::handler(Key::Up, app); } MouseEventKind::Down(MouseButton::Left) => { select_clicked_setting(mouse.row, list_area, app); @@ -709,12 +705,8 @@ fn fullscreen_view_playbar_area(app: &App) -> Option { } let root = Rect::new(0, 0, app.size.width, app.size.height); - let [_lyrics_area, playbar_area] = root.layout(&Layout::vertical([ - Constraint::Min(0), - Constraint::Length(FULLSCREEN_VIEW_PLAYBAR_HEIGHT), - ])); - - Some(playbar_area) + let (_, playbar_area) = fullscreen_view_layout(&app.user_config.behavior, root); + playbar_area } fn main_layout_areas(app: &App) -> Option { @@ -1099,6 +1091,60 @@ mod tests { assert_eq!(route.hovered_block, ActiveBlock::LyricsView); } + #[test] + fn fullscreen_view_playbar_area_uses_configured_height() { + let mut app = App::default(); + app.size = Size { + width: 160, + height: 50, + }; + app.user_config.behavior.playbar_height_rows = 8; + + let playbar_area = fullscreen_view_playbar_area(&app).expect("fullscreen playbar area"); + + assert_eq!(playbar_area, Rect::new(0, 42, 160, 8)); + } + + #[test] + fn fullscreen_view_playbar_area_is_hidden_when_height_is_zero() { + let mut app = App::default(); + app.size = Size { + width: 160, + height: 50, + }; + app.user_config.behavior.playbar_height_rows = 0; + + assert!(fullscreen_view_playbar_area(&app).is_none()); + } + + #[test] + fn click_hidden_lyrics_view_playbar_area_does_nothing() { + let mut app = App::default(); + app.size = Size { + width: 160, + height: 50, + }; + app.user_config.behavior.playbar_height_rows = 0; + app.push_navigation_stack(RouteId::LyricsView, ActiveBlock::LyricsView); + with_playbar_context(&mut app); + + let (initial_route_id, initial_active_block, initial_hovered_block) = { + let route = app.get_current_route(); + (route.id.clone(), route.active_block, route.hovered_block) + }; + + handler( + mouse_event(MouseEventKind::Down(MouseButton::Left), 80, 49), + &mut app, + ); + + assert!(!app.is_loading); + let route = app.get_current_route(); + assert_eq!(route.id, initial_route_id); + assert_eq!(route.active_block, initial_active_block); + assert_eq!(route.hovered_block, initial_hovered_block); + } + #[test] fn resized_playbar_control_click_still_maps_correctly() { let mut app = App::default(); diff --git a/src/tui/handlers/playlist.rs b/src/tui/handlers/playlist.rs index 91619651..45f9ddff 100644 --- a/src/tui/handlers/playlist.rs +++ b/src/tui/handlers/playlist.rs @@ -4,30 +4,33 @@ use crate::core::app::{App, DialogContext, PlaylistFolderItem, TrackTableContext use crate::infra::network::IoEvent; use crate::tui::event::Key; +/// Total items = playlists/folders + the "Add Playlist" entry at the bottom +fn total_display_count(app: &App) -> usize { + app.get_playlist_display_count() + 1 +} + pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::right_event(k) => common_key_events::handle_right_event(app), k if common_key_events::down_event(k) => { - let count = app.get_playlist_display_count(); + let count = total_display_count(app); if count > 0 { let current = app.selected_playlist_index.unwrap_or(0); app.selected_playlist_index = Some((current + 1) % count); } } k if common_key_events::up_event(k) => { - let count = app.get_playlist_display_count(); + let count = total_display_count(app); if count > 0 { let current = app.selected_playlist_index.unwrap_or(0); app.selected_playlist_index = Some(if current == 0 { count - 1 } else { current - 1 }); } } - k if common_key_events::high_event(k) => { - if app.get_playlist_display_count() > 0 { - app.selected_playlist_index = Some(0); - } + k if common_key_events::high_event(k) && total_display_count(app) > 0 => { + app.selected_playlist_index = Some(0); } k if common_key_events::middle_event(k) => { - let count = app.get_playlist_display_count(); + let count = total_display_count(app); if count > 0 { let next_index = if count.is_multiple_of(2) { count.saturating_sub(1) / 2 @@ -38,14 +41,18 @@ pub fn handler(key: Key, app: &mut App) { } } k if common_key_events::low_event(k) => { - let count = app.get_playlist_display_count(); + let count = total_display_count(app); if count > 0 { app.selected_playlist_index = Some(count - 1); } } Key::Enter => { if let Some(selected_idx) = app.selected_playlist_index { - if let Some(item) = app.get_playlist_display_item_at(selected_idx) { + let playlist_count = app.get_playlist_display_count(); + if selected_idx == playlist_count { + // "Add Playlist" entry selected + app.push_navigation_stack(RouteId::CreatePlaylist, ActiveBlock::CreatePlaylistForm); + } else if let Some(item) = app.get_playlist_display_item_at(selected_idx) { match item { PlaylistFolderItem::Folder(folder) => { // Navigate into/out of folder diff --git a/src/tui/handlers/search_results.rs b/src/tui/handlers/search_results.rs index fc7fdd7e..3eb9ed5d 100644 --- a/src/tui/handlers/search_results.rs +++ b/src/tui/handlers/search_results.rs @@ -477,20 +477,20 @@ pub fn handler(key: Key, app: &mut App) { SearchResultBlock::Empty => {} } } - k if common_key_events::high_event(k) => { - if app.search_results.selected_block != SearchResultBlock::Empty { - handle_high_press_on_selected_block(app); - } - } - k if common_key_events::middle_event(k) => { - if app.search_results.selected_block != SearchResultBlock::Empty { - handle_middle_press_on_selected_block(app); - } - } - k if common_key_events::low_event(k) => { - if app.search_results.selected_block != SearchResultBlock::Empty { - handle_low_press_on_selected_block(app) - } + k if common_key_events::high_event(k) + && app.search_results.selected_block != SearchResultBlock::Empty => + { + handle_high_press_on_selected_block(app); + } + k if common_key_events::middle_event(k) + && app.search_results.selected_block != SearchResultBlock::Empty => + { + handle_middle_press_on_selected_block(app); + } + k if common_key_events::low_event(k) + && app.search_results.selected_block != SearchResultBlock::Empty => + { + handle_low_press_on_selected_block(app) } // Handle pressing enter when block is selected to start playing track Key::Enter => match app.search_results.selected_block { diff --git a/src/tui/handlers/settings.rs b/src/tui/handlers/settings.rs index 6b0b65bd..39e6cf7b 100644 --- a/src/tui/handlers/settings.rs +++ b/src/tui/handlers/settings.rs @@ -44,10 +44,8 @@ fn handle_navigation(key: Key, app: &mut App) { fn handle_unsaved_changes_prompt(key: Key, app: &mut App) { match key { - Key::Char('y') | Key::Char('Y') => { - if save_settings(app) { - close_settings(app); - } + Key::Char('y') | Key::Char('Y') if save_settings(app) => { + close_settings(app); } Key::Char('n') | Key::Char('N') | Key::Esc => { close_settings(app); diff --git a/src/tui/ui/create_playlist.rs b/src/tui/ui/create_playlist.rs new file mode 100644 index 00000000..253f89a8 --- /dev/null +++ b/src/tui/ui/create_playlist.rs @@ -0,0 +1,221 @@ +use crate::core::app::{App, CreatePlaylistFocus, CreatePlaylistStage}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::Span, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, + Frame, +}; + +fn centered_rect(bounds: Rect, width_pct: u16, height_pct: u16) -> Rect { + let width = (bounds.width * width_pct / 100).max(1); + let height = (bounds.height * height_pct / 100).max(1); + let x = bounds.x + bounds.width.saturating_sub(width) / 2; + let y = bounds.y + bounds.height.saturating_sub(height) / 3; + Rect::new(x, y, width, height) +} + +pub fn draw_create_playlist_form(f: &mut Frame<'_>, app: &App) { + let area = centered_rect(f.area(), 80, 80); + f.render_widget(Clear, area); + + match app.create_playlist_stage { + CreatePlaylistStage::Name => draw_name_stage(f, app, area), + CreatePlaylistStage::AddTracks => draw_add_tracks_stage(f, app, area), + } +} + +fn draw_name_stage(f: &mut Frame<'_>, app: &App, area: Rect) { + let theme = &app.user_config.theme; + + let block = Block::default() + .title(Span::styled( + "Create Playlist (Esc to cancel)", + Style::default() + .fg(theme.header) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .style(theme.base_style()) + .border_style(Style::default().fg(theme.active)); + f.render_widget(block, area); + + let inner = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(area); + + let label = Paragraph::new("Playlist name:").style(theme.base_style()); + f.render_widget(label, inner[0]); + + let name_text: String = app.create_playlist_name.iter().collect(); + let input = Paragraph::new(name_text).style(theme.base_style()).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.active)), + ); + f.render_widget(input, inner[1]); + f.set_cursor_position(( + inner[1].x + 1 + app.create_playlist_name_cursor, + inner[1].y + 1, + )); + + let hint = Paragraph::new("Press Enter to continue, Esc to cancel") + .style(Style::default().fg(theme.inactive)); + f.render_widget(hint, inner[2]); +} + +fn draw_add_tracks_stage(f: &mut Frame<'_>, app: &App, area: Rect) { + let theme = &app.user_config.theme; + let name: String = app.create_playlist_name.iter().collect(); + let title = format!( + "Add Tracks to \"{}\" (Enter=create, Tab=switch panel, Esc=cancel)", + name + ); + + let block = Block::default() + .title(Span::styled( + title, + Style::default() + .fg(theme.header) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .style(theme.base_style()) + .border_style(Style::default().fg(theme.active)); + f.render_widget(block, area); + + let inner = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([Constraint::Length(3), Constraint::Min(5)]) + .split(area); + + // Search input + let search_text: String = app.create_playlist_search_input.iter().collect(); + let search_border_style = if app.create_playlist_focus == CreatePlaylistFocus::SearchInput { + Style::default().fg(theme.active) + } else { + Style::default().fg(theme.inactive) + }; + let search_input = Paragraph::new(search_text).style(theme.base_style()).block( + Block::default() + .title(Span::styled( + "Search (Enter to search)", + Style::default().fg(theme.header), + )) + .borders(Borders::ALL) + .border_style(search_border_style), + ); + f.render_widget(search_input, inner[0]); + if app.create_playlist_focus == CreatePlaylistFocus::SearchInput { + f.set_cursor_position(( + inner[0].x + 1 + app.create_playlist_search_cursor, + inner[0].y + 1, + )); + } + + // Two-panel area: results + added tracks + let panels = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner[1]); + + // Left: search results + let results_border_style = if app.create_playlist_focus == CreatePlaylistFocus::SearchResults { + Style::default().fg(theme.active) + } else { + Style::default().fg(theme.inactive) + }; + let result_items: Vec = app + .create_playlist_search_results + .iter() + .map(|t| { + let artist = t + .artists + .first() + .map(|a| a.name.as_str()) + .unwrap_or("Unknown"); + ListItem::new(format!("{} — {}", t.name, artist)).style(theme.base_style()) + }) + .collect(); + + let mut results_state = ListState::default(); + if app.create_playlist_focus == CreatePlaylistFocus::SearchResults + && !app.create_playlist_search_results.is_empty() + { + results_state.select(Some(app.create_playlist_selected_result)); + } + + let results_list = List::new(result_items) + .block( + Block::default() + .title(Span::styled( + "Results (Enter to add)", + Style::default().fg(theme.header), + )) + .borders(Borders::ALL) + .border_style(results_border_style), + ) + .highlight_style( + Style::default() + .fg(theme.selected) + .add_modifier(Modifier::BOLD), + ) + .style(theme.base_style()); + f.render_stateful_widget(results_list, panels[0], &mut results_state); + + // Right: added tracks + let added_border_style = if app.create_playlist_focus == CreatePlaylistFocus::AddedTracks { + Style::default().fg(theme.active) + } else { + Style::default().fg(theme.inactive) + }; + + let added_items: Vec = app + .create_playlist_tracks + .iter() + .map(|t| { + let artist = t + .artists + .first() + .map(|a| a.name.as_str()) + .unwrap_or("Unknown"); + ListItem::new(format!("{} — {}", t.name, artist)).style(theme.base_style()) + }) + .collect(); + + let mut added_state = ListState::default(); + if app.create_playlist_focus == CreatePlaylistFocus::AddedTracks + && !app.create_playlist_tracks.is_empty() + { + added_state.select(Some(app.create_playlist_selected_result)); + } + + let added_tracks_title = format!( + "Added ({}) — d=remove, Enter=create", + app.create_playlist_tracks.len() + ); + let added_list = List::new(added_items) + .block( + Block::default() + .title(Span::styled( + added_tracks_title, + Style::default().fg(theme.header), + )) + .borders(Borders::ALL) + .border_style(added_border_style), + ) + .highlight_style( + Style::default() + .fg(theme.selected) + .add_modifier(Modifier::BOLD), + ) + .style(theme.base_style()); + f.render_stateful_widget(added_list, panels[1], &mut added_state); +} diff --git a/src/tui/ui/help.rs b/src/tui/ui/help.rs index d14c3ba9..c440e585 100644 --- a/src/tui/ui/help.rs +++ b/src/tui/ui/help.rs @@ -339,6 +339,11 @@ pub fn get_help_docs(app: &App) -> Vec> { key_bindings.show_queue.to_string(), String::from("General"), ], + vec![ + String::from("Toggle saved state for currently playing track/episode"), + key_bindings.like_track.to_string(), + String::from("General"), + ], vec![ String::from("Open sort menu"), String::from(","), diff --git a/src/tui/ui/library.rs b/src/tui/ui/library.rs index 25ec6773..028956b5 100644 --- a/src/tui/ui/library.rs +++ b/src/tui/ui/library.rs @@ -64,12 +64,15 @@ pub fn draw_playlist_block(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { current_route.hovered_block == ActiveBlock::MyPlaylists, ); + let mut display_list = playlist_items; + display_list.push("+ Add Playlist".to_string()); + draw_selectable_list( f, app, layout_chunk, "Playlists", - &playlist_items, + &display_list, highlight_state, app.selected_playlist_index, ); diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs index c60c74a8..e94a3837 100644 --- a/src/tui/ui/mod.rs +++ b/src/tui/ui/mod.rs @@ -1,5 +1,6 @@ pub mod artist; pub mod audio_analysis; +pub mod create_playlist; pub mod discover; pub mod help; pub mod home; @@ -19,6 +20,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::home::draw_home; pub use self::library::draw_user_block; @@ -137,6 +139,7 @@ pub fn draw_routes(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { | RouteId::HelpMenu | RouteId::Queue | RouteId::Party => {} // These are drawn outside the main routed content area. - RouteId::Dialog => {} // This is handled in draw_dialog. + RouteId::Dialog => {} // This is handled in draw_dialog. + RouteId::CreatePlaylist => {} // This is drawn as an overlay via draw_create_playlist_form. }; } diff --git a/src/tui/ui/player.rs b/src/tui/ui/player.rs index 9ebacb9d..e0d570d9 100644 --- a/src/tui/ui/player.rs +++ b/src/tui/ui/player.rs @@ -1,6 +1,9 @@ -use crate::core::app::{ActiveBlock, App}; +use crate::core::{ + app::{ActiveBlock, App}, + layout::fullscreen_view_layout, +}; use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Position, Rect}, + layout::{Alignment, Constraint, Layout, Position, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, widgets::{ @@ -15,7 +18,6 @@ use rspotify::prelude::Id; use super::util::{ create_artist_string, display_track_progress, get_color, get_track_progress_percentage, - FULLSCREEN_VIEW_PLAYBAR_HEIGHT, }; const PLAYBAR_CONTROLS: [PlaybarControl; 8] = [ @@ -246,31 +248,33 @@ fn draw_playbar_controls(f: &mut Frame<'_>, app: &App, playbar_area: Rect) { } } +#[cfg(feature = "cover-art")] +fn center_rect_within(bounds: Rect, size: Rect) -> Rect { + Rect { + x: bounds.x + bounds.width.saturating_sub(size.width.min(bounds.width)) / 2, + y: bounds.y + bounds.height.saturating_sub(size.height.min(bounds.height)) / 2, + width: size.width.min(bounds.width), + height: size.height.min(bounds.height), + } +} + pub fn draw_lyrics_view(f: &mut Frame<'_>, app: &App) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(0), // Lyrics Area taking all available space above - Constraint::Length(FULLSCREEN_VIEW_PLAYBAR_HEIGHT), // Playbar at the bottom - ]) - .split(f.area()); - - draw_lyrics(f, app, chunks[0]); - draw_playbar(f, app, chunks[1]); + let (content_area, playbar_area) = fullscreen_view_layout(&app.user_config.behavior, f.area()); + + draw_lyrics(f, app, content_area); + if let Some(playbar_area) = playbar_area { + draw_playbar(f, app, playbar_area); + } } #[cfg(feature = "cover-art")] pub fn draw_cover_art_view(f: &mut Frame<'_>, app: &App) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(0), - Constraint::Length(FULLSCREEN_VIEW_PLAYBAR_HEIGHT), - ]) - .split(f.area()); - - draw_cover_art_content(f, app, chunks[0]); - draw_playbar(f, app, chunks[1]); + let (content_area, playbar_area) = fullscreen_view_layout(&app.user_config.behavior, f.area()); + + draw_cover_art_content(f, app, content_area); + if let Some(playbar_area) = playbar_area { + draw_playbar(f, app, playbar_area); + } } #[cfg(feature = "cover-art")] @@ -299,42 +303,36 @@ fn draw_cover_art_content(f: &mut Frame<'_>, app: &App, area: Rect) { return; } - // Reserve 3 rows at the bottom for song info (1 blank + 1 title + 1 artist) - let info_height = 3_u16; - let img_area_height = area.height.saturating_sub(info_height); - - // Calculate image dimensions for a square album cover - // Terminal characters are taller than wide, so we use a ratio to get a square. - let char_aspect_ratio = 1.9_f32; - - let max_height = img_area_height.saturating_sub(2); - let max_width = area.width.saturating_sub(2); - - let img_width_from_height = ((max_height as f32) * char_aspect_ratio).ceil() as u16; - - let (img_width, img_height) = if img_width_from_height > max_width { - let h = ((max_width as f32) / char_aspect_ratio).floor() as u16; - (max_width, h) + let show_title = track_name.is_some(); + let show_artist = show_title && artist_str.is_some(); + let info_height = if show_title { + 1 + 1 + u16::from(show_artist) } else { - (img_width_from_height, max_height) + 0 }; - - // Center the image horizontally, vertically within the image area - let x = area.x + (area.width.saturating_sub(img_width)) / 2; - let y = area.y + (img_area_height.saturating_sub(img_height)) / 2; - - let centered_area = Rect { - x, - y, - width: img_width, - height: img_height, + let image_bounds = Rect { + x: area.x, + y: area.y, + width: area.width, + height: area.height.saturating_sub(info_height), }; + let available_image_size = Rect::new( + 0, + 0, + image_bounds.width.saturating_sub(2), + image_bounds.height.saturating_sub(2), + ); + let fitted_image_size = app + .cover_art + .fullscreen_size_for(available_image_size) + .unwrap_or(available_image_size); + let centered_area = center_rect_within(image_bounds, fitted_image_size); app.cover_art.render_fullscreen(f, centered_area); // Draw song info below the cover art if let Some(name) = track_name { - let title_y = y + img_height + 1; + let title_y = centered_area.y + centered_area.height + 1; if title_y < area.y + area.height { let title = Paragraph::new(name) .style( @@ -536,7 +534,7 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { current_playback_context.device.name, shuffle_text, repeat_text, - current_playback_context.device.volume_percent.unwrap_or(0) + app.desired_volume() ); if let Some(session) = &app.party_session { @@ -825,4 +823,13 @@ mod tests { PlaybarControl::VolumeUp ); } + + #[cfg(feature = "cover-art")] + #[test] + fn center_rect_within_centers_smaller_rect() { + let bounds = Rect::new(10, 20, 100, 50); + let size = Rect::new(0, 0, 80, 40); + + assert_eq!(center_rect_within(bounds, size), Rect::new(20, 25, 80, 40)); + } } diff --git a/src/tui/ui/search.rs b/src/tui/ui/search.rs index ebc1680a..fc2f11ce 100644 --- a/src/tui/ui/search.rs +++ b/src/tui/ui/search.rs @@ -16,8 +16,8 @@ use super::util::{ }; const COMPACT_TOP_ROW_THRESHOLD: u16 = 60; -const COMPACT_HELP_WIDTH: u16 = 8; -const COMPACT_SETTINGS_WIDTH: u16 = 12; +const COMPACT_HELP_WIDTH: u16 = 6; +const COMPACT_SETTINGS_WIDTH: u16 = 10; pub fn draw_input_and_help_box(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { let compact_top_row = layout_chunk.width < COMPACT_TOP_ROW_THRESHOLD; @@ -40,8 +40,8 @@ pub fn draw_input_and_help_box(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) } else { [ Constraint::Percentage(80), - Constraint::Percentage(10), - Constraint::Percentage(10), + Constraint::Percentage(8), + Constraint::Percentage(12), ] }; @@ -88,23 +88,20 @@ pub fn draw_input_and_help_box(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) f.render_widget(input, input_area); let help_content = if show_loading { - (app.user_config.theme.hint, "Help", "...") + (app.user_config.theme.hint, "...") } else if compact_top_row { - (app.user_config.theme.inactive, "Help", "?") + (app.user_config.theme.inactive, "?") } else { - (app.user_config.theme.inactive, "Help", "Type ?") + (app.user_config.theme.inactive, "Type ?") }; let block = Block::default() - .title(Span::styled( - help_content.1, - Style::default().fg(help_content.0), - )) + .title(Span::styled("Help", Style::default().fg(help_content.0))) .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(help_content.0)); - let lines = Text::from(help_content.2); + let lines = Text::from(help_content.1); let help = Paragraph::new(lines).block(block).style( Style::default() .fg(help_content.0) @@ -112,29 +109,31 @@ pub fn draw_input_and_help_box(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) ); f.render_widget(help, help_area); - let settings_content = if compact_top_row { - ("Settings", "Open") + let settings_keybind_string = app + .effective_open_settings_key() + .to_string() + .trim_matches(|c| c == '<' || c == '>') + .to_string(); + let settings_hint = if compact_top_row { + settings_keybind_string } else { - ("Settings", "Click") + format!("Type {}", settings_keybind_string) }; - let settings_color = app.user_config.theme.inactive; let settings_block = Block::default() .title(Span::styled( - settings_content.0, + "Settings", Style::default().fg(settings_color), )) .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(settings_color)); - let settings = Paragraph::new(settings_content.1) - .block(settings_block) - .style( - Style::default() - .fg(settings_color) - .bg(app.user_config.theme.background), - ); + let settings = Paragraph::new(settings_hint).block(settings_block).style( + Style::default() + .fg(settings_color) + .bg(app.user_config.theme.background), + ); f.render_widget(settings, settings_area); } diff --git a/src/tui/ui/tables.rs b/src/tui/ui/tables.rs index 1be72203..b29a1b37 100644 --- a/src/tui/ui/tables.rs +++ b/src/tui/ui/tables.rs @@ -77,24 +77,37 @@ pub fn draw_artist_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { current_route.active_block == ActiveBlock::Artists, current_route.hovered_block == ActiveBlock::Artists, ); - let items = app - .artists - .iter() - .map(|item| TableItem { - id: item.id.id().to_string(), - format: vec![item.name.to_owned()], - }) - .collect::>(); - draw_table( - f, - app, - layout_chunk, - ("Artists", &header), - &items, - app.artists_list_index, - highlight_state, - ) + if let Some(saved_artists) = app.library.saved_artists.get_results(None) { + let items = saved_artists + .items + .iter() + .map(|item| TableItem { + id: item.id.id().to_string(), + format: vec![item.name.to_owned()], + }) + .collect::>(); + + draw_table( + f, + app, + layout_chunk, + ("Artists", &header), + &items, + app.artists_list_index, + highlight_state, + ) + } else { + draw_table( + f, + app, + layout_chunk, + ("Artists", &header), + &[], + app.artists_list_index, + highlight_state, + ) + } } pub fn draw_podcast_table(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { diff --git a/src/tui/ui/util.rs b/src/tui/ui/util.rs index 28d5805e..826f6f59 100644 --- a/src/tui/ui/util.rs +++ b/src/tui/ui/util.rs @@ -10,7 +10,6 @@ use ratatui::{ use rspotify::model::artist::SimplifiedArtist; use std::time::Duration; -pub const FULLSCREEN_VIEW_PLAYBAR_HEIGHT: u16 = 6; pub const SMALL_TERMINAL_WIDTH: u16 = 150; pub const SMALL_TERMINAL_HEIGHT: u16 = 45;