diff --git a/CHANGELOG.md b/CHANGELOG.md index 44f0b89a..29ea1822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **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. + ### 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)). diff --git a/src/core/app.rs b/src/core/app.rs index 1a539fcf..41ea630f 100644 --- a/src/core/app.rs +++ b/src/core/app.rs @@ -3184,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(), @@ -3603,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/user_config.rs b/src/core/user_config.rs index 78e7fd19..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)] @@ -778,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, @@ -889,6 +892,7 @@ impl UserConfig { to_keys!(open_settings); to_keys!(save_settings); to_keys!(listening_party); + to_keys!(like_track); Ok(()) } @@ -1242,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/tui/handlers/mod.rs b/src/tui/handlers/mod.rs index 9963f53c..5dd43fee 100644 --- a/src/tui/handlers/mod.rs +++ b/src/tui/handlers/mod.rs @@ -184,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) { @@ -462,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() { @@ -500,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/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(","),