Repository: aome510/spotify-player (language: rust)

Issue:
Remap key to key combination
I'm thinking how to bind y to the equivalent of 'a 5'. So, current song, copy link.
I think either having it so you can bind to a sequence of commands, or bind a key to a sequence of keys would be nice.
Something like
[[keymaps]]
command = "ShowActionsOnCurrentTrack; CopyTrackLink"
key_sequence = "y"
or
[[keymaps]]
command = "a 5" // maybe a different name instead of command? idk
key_sequence = "y"



Discussion / hints:
It's better to define a separate mapping for actions, for example

```toml
[[actions]]
action = "GoToTrackRadio"
key = "r"

[[actions]]
action = "AddToLikedTracks"
key = "y"
```

Fix patch:
diff --git a/README.md b/README.md
index ba54e826..13dd7d47 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,7 @@ A Spotify Premium account is **required**.
 - [Rust and cargo](https://www.rust-lang.org/tools/install) as the build dependencies
 - install `openssl`, `alsa-lib` (`streaming` feature), `libdbus` (`media-control` feature).
   - For example, on Debian based systems, run the below command to install application's dependencies:
+
     ```shell
     sudo apt install libssl-dev libasound2-dev libdbus-1-dev
     ```
@@ -372,10 +373,28 @@ To add new shortcuts or modify the default shortcuts, please refer to the [keyma
 
 ### Actions
 
-A list of actions is available for each type of Spotify item (track, album, artist, or playlist).
-For example, the list of available actions on a track is `[GoToAlbum, GoToArtist, GoToTrackRadio, GoToArtistRadio, GoToAlbumRadio, AddToPlaylist, DeleteFromCurrentPlaylist, AddToLikedTracks, DeleteFromLikedTracks]`.
-
-To get the list of actions on an item, call the `ShowActionsOnCurrentTrack` command or `ShowActionsOnSelectedItem` command, then press enter (default binding for `ChooseSelected` command) to initiate the selected action.
+A general list of actions is available; however, not all Spotify items (track, album, artist, or playlist) implement each action. To get the list of available actions on an item, call the `ShowActionsOnCurrentTrack` command or the `ShowActionsOnSelectedItem` command, then press enter (default binding for the `ChooseSelected` command) to initiate the selected action. Some actions may not appear in the popup but can be bound to a shortcut.
+
+List of available actions:
+
+- `GoToArtist`
+- `GoToAlbum`
+- `GoToRadio`
+- `AddToLibrary`
+- `AddToPlaylist`
+- `AddToQueue`
+- `AddToLiked`
+- `DeleteFromLiked`
+- `DeleteFromLibrary`
+- `DeleteFromPlaylist`
+- `ShowActionsOnAlbum`
+- `ShowActionsOnArtist`
+- `ToggleLiked`
+- `CopyLink`
+- `Follow`
+- `Unfollow`
+
+These actions can also be bound to a shortcut. To add new shortcuts, please refer to the [actions section](docs/config.md#actions) in the configuration documentation.
 
 ### Search Page
 
diff --git a/docs/config.md b/docs/config.md
index ce45de3c..7e6b8404 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -257,3 +257,20 @@ key_sequence = "M-enter"
 command = "None"
 key_sequence = "q"
 ```
+
+## Actions
+
+Actions are located in the same `keymap.toml` file as keymaps. An action can be triggered by a key sequence that is not bound to any command. Once the mapped key sequence is pressed, the corresponding action will be triggered **on the currently selected item**. For example,
+a list of actions can be found [here](../README.md#actions).
+
+```toml
+[[actions]]
+action = "GoToArtist"
+key_sequence = "g A"
+[[actions]]
+action = "GoToAlbum"
+key_sequence = "g B"
+[[actions]]
+action="ToggleLiked"
+key_sequence="C-l"
+```
diff --git a/spotify_player/src/command.rs b/spotify_player/src/command.rs
index 72f167d8..a525e5e2 100644
--- a/spotify_player/src/command.rs
+++ b/spotify_player/src/command.rs
@@ -78,117 +78,138 @@ pub enum Command {
     CreatePlaylist,
 }
 
-#[derive(Debug, Copy, Clone)]
-pub enum TrackAction {
+#[derive(Clone, Copy, Debug, Deserialize)]
+pub enum Action {
     GoToArtist,
     GoToAlbum,
-    GoToTrackRadio,
+    GoToRadio,
+    AddToLibrary,
+    AddToPlaylist,
+    AddToQueue,
+    AddToLiked,
+    DeleteFromLiked,
+    DeleteFromLibrary,
+    DeleteFromPlaylist,
     ShowActionsOnAlbum,
     ShowActionsOnArtist,
-    AddToQueue,
-    AddToPlaylist,
-    DeleteFromCurrentPlaylist,
-    AddToLikedTracks,
-    DeleteFromLikedTracks,
-    CopyTrackLink,
+    ToggleLiked,
+    CopyLink,
+    Follow,
+    Unfollow,
 }
 
-#[derive(Debug, Copy, Clone)]
-pub enum AlbumAction {
-    GoToArtist,
-    GoToAlbumRadio,
-    ShowActionsOnArtist,
-    AddToLibrary,
-    DeleteFromLibrary,
-    CopyAlbumLink,
-    AddToQueue,
+#[derive(Debug)]
+pub enum ActionContext {
+    Track(Track),
+    Album(Album),
+    Artist(Artist),
+    Playlist(Playlist),
 }
 
-#[derive(Debug, Copy, Clone)]
-pub enum ArtistAction {
-    GoToArtistRadio,
-    Follow,
-    Unfollow,
-    CopyArtistLink,
+pub enum CommandOrAction {
+    Command(Command),
+    Action(Action),
 }
 
-#[derive(Debug, Copy, Clone)]
-pub enum PlaylistAction {
-    GoToPlaylistRadio,
-    AddToLibrary,
-    DeleteFromLibrary,
-    CopyPlaylistLink,
+impl From<Track> for ActionContext {
+    fn from(v: Track) -> Self {
+        Self::Track(v)
+    }
+}
+
+impl From<Artist> for ActionContext {
+    fn from(v: Artist) -> Self {
+        Self::Artist(v)
+    }
+}
+
+impl From<Album> for ActionContext {
+    fn from(v: Album) -> Self {
+        Self::Album(v)
+    }
+}
+
+impl From<Playlist> for ActionContext {
+    fn from(v: Playlist) -> Self {
+        Self::Playlist(v)
+    }
+}
+
+impl ActionContext {
+    pub fn get_available_actions(&self, data: &DataReadGuard) -> Vec<Action> {
+        match self {
+            Self::Track(track) => construct_track_actions(track, data),
+            Self::Album(album) => construct_album_actions(album, data),
+            Self::Artist(artist) => construct_artist_actions(artist, data),
+            Self::Playlist(playlist) => construct_playlist_actions(playlist, data),
+        }
+    }
 }
 
 /// constructs a list of actions on a track
-pub fn construct_track_actions(track: &Track, data: &DataReadGuard) -> Vec<TrackAction> {
+pub fn construct_track_actions(track: &Track, data: &DataReadGuard) -> Vec<Action> {
     let mut actions = vec![
-        TrackAction::GoToArtist,
-        TrackAction::GoToAlbum,
-        TrackAction::GoToTrackRadio,
-        TrackAction::ShowActionsOnAlbum,
-        TrackAction::ShowActionsOnArtist,
-        TrackAction::CopyTrackLink,
-        TrackAction::AddToPlaylist,
-        TrackAction::AddToQueue,
+        Action::GoToArtist,
+        Action::GoToAlbum,
+        Action::GoToRadio,
+        Action::ShowActionsOnAlbum,
+        Action::ShowActionsOnArtist,
+        Action::CopyLink,
+        Action::AddToPlaylist,
+        Action::AddToQueue,
     ];
 
-    // check if the track is a liked track
     if data.user_data.is_liked_track(track) {
-        actions.push(TrackAction::DeleteFromLikedTracks);
+        actions.push(Action::AddToLiked);
     } else {
-        actions.push(TrackAction::AddToLikedTracks);
+        actions.push(Action::DeleteFromLiked);
     }
 
     actions
 }
 
 /// constructs a list of actions on an album
-pub fn construct_album_actions(album: &Album, data: &DataReadGuard) -> Vec<AlbumAction> {
+pub fn construct_album_actions(album: &Album, data: &DataReadGuard) -> Vec<Action> {
     let mut actions = vec![
-        AlbumAction::GoToArtist,
-        AlbumAction::GoToAlbumRadio,
-        AlbumAction::ShowActionsOnArtist,
-        AlbumAction::CopyAlbumLink,
-        AlbumAction::AddToQueue,
+        Action::GoToArtist,
+        Action::GoToRadio,
+        Action::ShowActionsOnArtist,
+        Action::CopyLink,
+        Action::AddToQueue,
     ];
     if data.user_data.saved_albums.iter().any(|a| a.id == album.id) {
-        actions.push(AlbumAction::DeleteFromLibrary);
+        actions.push(Action::DeleteFromLibrary);
     } else {
-        actions.push(AlbumAction::AddToLibrary);
+        actions.push(Action::AddToLibrary);
     }
     actions
 }
 
 /// constructs a list of actions on an artist
-pub fn construct_artist_actions(artist: &Artist, data: &DataReadGuard) -> Vec<ArtistAction> {
-    let mut actions = vec![ArtistAction::GoToArtistRadio, ArtistAction::CopyArtistLink];
+pub fn construct_artist_actions(artist: &Artist, data: &DataReadGuard) -> Vec<Action> {
+    let mut actions = vec![Action::GoToRadio, Action::CopyLink];
+
     if data
         .user_data
         .followed_artists
         .iter()
         .any(|a| a.id == artist.id)
     {
-        actions.push(ArtistAction::Unfollow);
+        actions.push(Action::Unfollow);
     } else {
-        actions.push(ArtistAction::Follow);
+        actions.push(Action::Follow);
     }
     actions
 }
 
 /// constructs a list of actions on an playlist
-pub fn construct_playlist_actions(
-    playlist: &Playlist,
-    data: &DataReadGuard,
-) -> Vec<PlaylistAction> {
-    let mut actions = vec![
-        PlaylistAction::GoToPlaylistRadio,
-        PlaylistAction::CopyPlaylistLink,
-    ];
+pub fn construct_playlist_actions(playlist: &Playlist, data: &DataReadGuard) -> Vec<Action> {
+    let mut actions = vec![Action::GoToRadio, Action::CopyLink];
+
     if data.user_data.playlists.iter().any(|a| a.id == playlist.id) {
-        actions.push(PlaylistAction::DeleteFromLibrary);
+        actions.push(Action::DeleteFromLibrary);
     } else {
-        actions.push(PlaylistAction::AddToLibrary);
+        actions.push(Action::AddToLibrary);
     }
     actions
 }
diff --git a/spotify_player/src/config/keymap.rs b/spotify_player/src/config/keymap.rs
index a17e76c9..3a3e3e49 100644
--- a/spotify_player/src/config/keymap.rs
+++ b/spotify_player/src/config/keymap.rs
@@ -1,5 +1,5 @@
 use crate::{
-    command::Command,
+    command::{Action, Command, CommandOrAction},
     key::{Key, KeySequence},
 };
 use anyhow::Result;
@@ -10,6 +10,8 @@ use serde::Deserialize;
 pub struct KeymapConfig {
     #[serde(default)]
     pub keymaps: Vec<Keymap>,
+    #[serde(default)]
+    pub actions: Vec<ActionMap>,
 }
 
 #[derive(Clone, Debug, Deserialize)]
@@ -19,9 +21,17 @@ pub struct Keymap {
     pub command: Command,
 }
 
+#[derive(Clone, Debug, Deserialize)]
+/// A keymap that triggers an `Action` when a key sequence is pressed
+pub struct ActionMap {
+    pub key_sequence: KeySequence,
+    pub action: Action,
+}
+
 impl Default for KeymapConfig {
     fn default() -> Self {
         KeymapConfig {
+            actions: vec![],
             keymaps: vec![
                 Keymap {
                     key_sequence: "n".into(),
@@ -325,12 +335,14 @@ impl KeymapConfig {
                 );
             }
             Ok(content) => {
-                let mut keymaps = toml::from_str::<Self>(&content)?.keymaps;
-                std::mem::swap(&mut self.keymaps, &mut keymaps);
+                let mut parsed = toml::from_str::<Self>(&content)?;
+                std::mem::swap(&mut self.keymaps, &mut parsed.keymaps);
+                std::mem::swap(&mut self.actions, &mut parsed.actions);
+
                 // a dumb approach (with quadratic complexity) to merge two different keymap arrays
                 // while keeping the invariant:
                 // - each `KeySequence` is mapped to only one `Command`.
-                keymaps.into_iter().for_each(|keymap| {
+                parsed.keymaps.into_iter().for_each(|keymap| {
                     if !self
                         .keymaps
                         .iter()
@@ -339,6 +351,15 @@ impl KeymapConfig {
                         self.keymaps.push(keymap);
                     }
                 });
+                parsed.actions.into_iter().for_each(|action| {
+                    if !self
+                        .actions
+                        .iter()
+                        .any(|k| k.key_sequence == action.key_sequence)
+                    {
+                        self.actions.push(action);
+                    }
+                });
             }
         }
         Ok(())
@@ -352,6 +373,21 @@ impl KeymapConfig {
             .collect()
     }
 
+    /// finds all actions whose mapped key sequence has a given `prefix` key sequence as its prefix
+    pub fn find_matched_prefix_actions(&self, prefix: &KeySequence) -> Vec<&ActionMap> {
+        self.actions
+            .iter()
+            .filter(|&action| prefix.is_prefix(&action.key_sequence))
+            .collect()
+    }
+
+    /// checks if there is any command or action that has a given `prefix` key sequence as its prefix
+    pub fn has_matched_prefix(&self, prefix: &KeySequence) -> bool {
+        let keymaps = self.find_matched_prefix_keymaps(prefix);
+        let actions = self.find_matched_prefix_actions(prefix);
+        !keymaps.is_empty() || !actions.is_empty()
+    }
+
     /// finds a command from a mapped key sequence
     pub fn find_command_from_key_sequence(&self, key_sequence: &KeySequence) -> Option<Command> {
         self.keymaps
@@ -359,6 +395,28 @@ impl KeymapConfig {
             .find(|&keymap| keymap.key_sequence == *key_sequence)
             .map(|keymap| keymap.command)
     }
+
+    /// finds an action from a mapped key sequence
+    pub fn find_action_from_key_sequence(&self, key_sequence: &KeySequence) -> Option<Action> {
+        self.actions
+            .iter()
+            .find(|&action| action.key_sequence == *key_sequence)
+            .map(|action| action.action)
+    }
+
+    /// finds a command or action from a mapped key sequence
+    pub fn find_command_or_action_from_key_sequence(
+        &self,
+        key_sequence: &KeySequence,
+    ) -> Option<CommandOrAction> {
+        if let Some(command) = self.find_command_from_key_sequence(key_sequence) {
+            return Some(CommandOrAction::Command(command));
+        }
+        if let Some(action) = self.find_action_from_key_sequence(key_sequence) {
+            return Some(CommandOrAction::Action(action));
+        }
+        None
+    }
 }
 
 impl Keymap {
diff --git a/spotify_player/src/event/clipboard.rs b/spotify_player/src/event/clipboard.rs
index 6ce040fd..51c11cbd 100644
--- a/spotify_player/src/event/clipboard.rs
+++ b/spotify_player/src/event/clipboard.rs
@@ -4,7 +4,7 @@ use anyhow::Result;
 
 use crate::config::Command;
 
-pub static CLIPBOARD_PROVIDER: OnceLock<Box<dyn ClipboardProvider>> = OnceLock::new();
+static CLIPBOARD_PROVIDER: OnceLock<Box<dyn ClipboardProvider>> = OnceLock::new();
 
 pub trait ClipboardProvider: Send + Sync {
     fn get_contents(&self) -> Result<String>;
@@ -121,3 +121,15 @@ fn binary_exists(command: &'static str) -> bool {
 fn env_var_is_set(env_var_name: &str) -> bool {
     std::env::var_os(env_var_name).is_some()
 }
+
+pub fn get_clipboard_content() -> Result<String> {
+    CLIPBOARD_PROVIDER
+        .get_or_init(|| get_clipboard_provider())
+        .get_contents()
+}
+
+pub fn execute_copy_command(text: String) -> Result<()> {
+    CLIPBOARD_PROVIDER
+        .get_or_init(|| get_clipboard_provider())
+        .set_contents(text)
+}
diff --git a/spotify_player/src/event/mod.rs b/spotify_player/src/event/mod.rs
index d3f8e90d..80b0c1fd 100644
--- a/spotify_player/src/event/mod.rs
+++ b/spotify_player/src/event/mod.rs
@@ -1,18 +1,18 @@
 use crate::{
     client::{ClientRequest, PlayerRequest},
-    command::{self, Command},
+    command::{self, Action, ActionContext, Command},
     config,
     key::{Key, KeySequence},
     state::*,
     ui::single_line_input::LineInput,
-    utils::new_list_state,
+    utils::{new_list_state, parse_uri},
 };
 
 #[cfg(feature = "lyric-finder")]
 use crate::utils::map_join;
 use anyhow::{Context as _, Result};
 
-use self::clipboard::{get_clipboard_provider, CLIPBOARD_PROVIDER};
+use clipboard::{execute_copy_command, get_clipboard_content};
 
 mod clipboard;
 mod page;
@@ -89,10 +89,7 @@ fn handle_key_event(
     // check if the current key sequence matches any keymap's prefix
     // if not, reset the key sequence
     let keymap_config = &config::get_config().keymap_config;
-    if keymap_config
-        .find_matched_prefix_keymaps(&key_sequence)
-        .is_empty()
-    {
+    if !keymap_config.has_matched_prefix(&key_sequence) {
         key_sequence = KeySequence { keys: vec![key] };
     }
 
@@ -125,6 +122,207 @@ fn handle_key_event(
     Ok(())
 }
 
+pub fn handle_action_in_context(
+    action: Action,
+    context: ActionContext,
+    client_pub: &flume::Sender<ClientRequest>,
+    data: &DataReadGuard,
+    ui: &mut UIStateGuard,
+) -> Result<()> {
+    match context {
+        ActionContext::Track(track) => match action {
+            Action::GoToAlbum => {
+                if let Some(album) = track.album {
+                    let context_id = ContextId::Album(
+                        AlbumId::from_uri(&parse_uri(&album.id.uri()))?.into_static(),
+                    );
+                    ui.new_page(PageState::Context {
+                        id: None,
+                        context_page_type: ContextPageType::Browsing(context_id),
+                        state: None,
+                    });
+                }
+            }
+            Action::GoToArtist => {
+                ui.popup = Some(PopupState::ArtistList(
+                    ArtistPopupAction::Browse,
+                    track.artists,
+                    new_list_state(),
+                ));
+            }
+            Action::AddToQueue => {
+                client_pub.send(ClientRequest::AddTrackToQueue(track.id))?;
+                ui.popup = None;
+            }
+            Action::CopyLink => {
+                let track_url = format!("https://open.spotify.com/track/{}", track.id.id());
+                execute_copy_command(track_url)?;
+                ui.popup = None;
+            }
+            Action::AddToPlaylist => {
+                client_pub.send(ClientRequest::GetUserPlaylists)?;
+                ui.popup = Some(PopupState::UserPlaylistList(
+                    PlaylistPopupAction::AddTrack(track.id),
+                    new_list_state(),
+                ));
+            }
+            Action::ToggleLiked => {
+                if data.user_data.is_liked_track(&track) {
+                    client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Track(track.id)))?;
+                } else {
+                    client_pub.send(ClientRequest::AddToLibrary(Item::Track(track)))?;
+                }
+                ui.popup = None;
+            }
+            Action::AddToLiked => {
+                client_pub.send(ClientRequest::AddToLibrary(Item::Track(track)))?;
+                ui.popup = None;
+            }
+            Action::DeleteFromLiked => {
+                client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Track(track.id)))?;
+                ui.popup = None;
+            }
+            Action::GoToRadio => {
+                let uri = track.id.uri();
+                let name = track.name;
+                ui.new_radio_page(&uri);
+                client_pub.send(ClientRequest::GetRadioTracks {
+                    seed_uri: uri,
+                    seed_name: name,
+                })?;
+            }
+            Action::ShowActionsOnArtist => {
+                ui.popup = Some(PopupState::ArtistList(
+                    ArtistPopupAction::ShowActions,
+                    track.artists,
+                    new_list_state(),
+                ));
+            }
+            Action::ShowActionsOnAlbum => {
+                if let Some(album) = track.album {
+                    let context = ActionContext::Album(album.clone());
+                    ui.popup = Some(PopupState::ActionList(
+                        ActionListItem::Album(album, context.get_available_actions(data)),
+                        new_list_state(),
+                    ));
+                }
+            }
+            Action::DeleteFromPlaylist => {
+                if let PageState::Context {
+                    id: Some(ContextId::Playlist(playlist_id)),
+                    ..
+                } = ui.current_page()
+                {
+                    client_pub.send(ClientRequest::DeleteTrackFromPlaylist(
+                        playlist_id.clone_static(),
+                        track.id,
+                    ))?;
+                }
+                ui.popup = None;
+            }
+            _ => {}
+        },
+        ActionContext::Album(album) => match action {
+            Action::GoToArtist => {
+                ui.popup = Some(PopupState::ArtistList(
+                    ArtistPopupAction::Browse,
+                    album.artists,
+                    new_list_state(),
+                ));
+            }
+            Action::GoToRadio => {
+                let uri = album.id.uri();
+                let name = album.name;
+                ui.new_radio_page(&uri);
+                client_pub.send(ClientRequest::GetRadioTracks {
+                    seed_uri: uri,
+                    seed_name: name,
+                })?;
+            }
+            Action::ShowActionsOnArtist => {
+                ui.popup = Some(PopupState::ArtistList(
+                    ArtistPopupAction::ShowActions,
+                    album.artists,
+                    new_list_state(),
+                ));
+            }
+            Action::AddToLibrary => {
+                client_pub.send(ClientRequest::AddToLibrary(Item::Album(album)))?;
+                ui.popup = None;
+            }
+            Action::DeleteFromLibrary => {
+                client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Album(album.id)))?;
+                ui.popup = None;
+            }
+            Action::CopyLink => {
+                let album_url = format!("https://open.spotify.com/album/{}", album.id.id());
+                execute_copy_command(album_url)?;
+                ui.popup = None;
+            }
+            Action::AddToQueue => {
+                client_pub.send(ClientRequest::AddAlbumToQueue(album.id))?;
+                ui.popup = None;
+            }
+            _ => {}
+        },
+        ActionContext::Artist(artist) => match action {
+            Action::Follow => {
+                client_pub.send(ClientRequest::AddToLibrary(Item::Artist(artist)))?;
+                ui.popup = None;
+            }
+            Action::Unfollow => {
+                client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Artist(artist.id)))?;
+                ui.popup = None;
+            }
+            Action::CopyLink => {
+                let artist_url = format!("https://open.spotify.com/artist/{}", artist.id.id());
+                execute_copy_command(artist_url)?;
+                ui.popup = None;
+            }
+            Action::GoToRadio => {
+                let uri = artist.id.uri();
+                let name = artist.name;
+                ui.new_radio_page(&uri);
+                client_pub.send(ClientRequest::GetRadioTracks {
+                    seed_uri: uri,
+                    seed_name: name,
+                })?;
+            }
+            _ => {}
+        },
+        ActionContext::Playlist(playlist) => match action {
+            Action::AddToLibrary => {
+                client_pub.send(ClientRequest::AddToLibrary(Item::Playlist(playlist)))?;
+                ui.popup = None;
+            }
+            Action::GoToRadio => {
+                let uri = playlist.id.uri();
+                let name = playlist.name;
+                ui.new_radio_page(&uri);
+                client_pub.send(ClientRequest::GetRadioTracks {
+                    seed_uri: uri,
+                    seed_name: name,
+                })?;
+            }
+            Action::CopyLink => {
+                let playlist_url =
+                    format!("https://open.spotify.com/playlist/{}", playlist.id.id());
+                execute_copy_command(playlist_url)?;
+                ui.popup = None;
+            }
+            Action::DeleteFromLibrary => {
+                client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Playlist(
+                    playlist.id,
+                )))?;
+                ui.popup = None;
+            }
+            _ => {}
+        },
+    }
+
+    Ok(())
+}
+
 /// Handle a global command that is not specific to any page/popup
 fn handle_global_command(
     command: Command,
@@ -400,9 +598,3 @@ fn handle_global_command(
     }
     Ok(true)
 }
-
-fn get_clipboard_content() -> Result<String> {
-    CLIPBOARD_PROVIDER
-        .get_or_init(|| get_clipboard_provider())
-        .get_contents()
-}
diff --git a/spotify_player/src/event/page.rs b/spotify_player/src/event/page.rs
index ff443007..cf0da228 100644
--- a/spotify_player/src/event/page.rs
+++ b/spotify_player/src/event/page.rs
@@ -1,4 +1,5 @@
 use anyhow::Context as _;
+use command::CommandOrAction;
 
 use super::*;
 
@@ -15,23 +16,66 @@ pub fn handle_key_sequence_for_page(
         return handle_key_sequence_for_search_page(key_sequence, client_pub, state, ui);
     }
 
-    let command = match config::get_config()
+    match config::get_config()
         .keymap_config
-        .find_command_from_key_sequence(key_sequence)
+        .find_command_or_action_from_key_sequence(key_sequence)
     {
-        Some(command) => command,
-        None => return Ok(false),
-    };
+        Some(CommandOrAction::Command(command)) => match page_type {
+            PageType::Search => anyhow::bail!("page search type should already be handled!"),
+            PageType::Library => handle_command_for_library_page(command, client_pub, ui, state),
+            PageType::Context => handle_command_for_context_page(command, client_pub, ui, state),
+            PageType::Browse => handle_command_for_browse_page(command, client_pub, ui, state),
+            #[cfg(feature = "lyric-finder")]
+            PageType::Lyric => handle_command_for_lyric_page(command, ui),
+            PageType::Queue => handle_command_for_queue_page(command, ui),
+            PageType::CommandHelp => handle_command_for_command_help_page(command, ui),
+        },
+        Some(CommandOrAction::Action(action)) => match page_type {
+            PageType::Search => anyhow::bail!("page search type should already be handled!"),
+            PageType::Library => handle_action_for_library_page(action, client_pub, ui, state),
+            PageType::Context => {
+                window::handle_action_for_focused_context_page(action, client_pub, ui, state)
+            }
+            PageType::Browse => handle_action_for_browse_page(action, client_pub, ui, state),
+            _ => Ok(false),
+        },
+        None => Ok(false),
+    }
+}
 
-    match page_type {
-        PageType::Search => anyhow::bail!("page search type should already be handled!"),
-        PageType::Library => handle_command_for_library_page(command, client_pub, ui, state),
-        PageType::Context => handle_command_for_context_page(command, client_pub, ui, state),
-        PageType::Browse => handle_command_for_browse_page(command, client_pub, ui, state),
-        #[cfg(feature = "lyric-finder")]
-        PageType::Lyric => handle_command_for_lyric_page(command, ui),
-        PageType::Queue => handle_command_for_queue_page(command, ui),
-        PageType::CommandHelp => handle_command_for_command_help_page(command, ui),
+fn handle_action_for_library_page(
+    action: Action,
+    client_pub: &flume::Sender<ClientRequest>,
+    ui: &mut UIStateGuard,
+    state: &SharedState,
+) -> Result<bool> {
+    let data = state.data.read();
+    let focus_state = match ui.current_page() {
+        PageState::Library { state } => state.focus,
+        _ => anyhow::bail!("expect a library page state"),
+    };
+    match focus_state {
+        LibraryFocusState::Playlists => window::handle_action_for_selected_item(
+            action,
+            ui.search_filtered_items(&data.user_data.playlists),
+            &data,
+            ui,
+            client_pub,
+        ),
+        LibraryFocusState::SavedAlbums => window::handle_action_for_selected_item(
+            action,
+            ui.search_filtered_items(&data.user_data.saved_albums),
+            &data,
+            ui,
+            client_pub,
+        ),
+        LibraryFocusState::FollowedArtists => window::handle_action_for_selected_item(
+            action,
+            ui.search_filtered_items(&data.user_data.followed_artists),
+            &data,
+            ui,
+            client_pub,
+        ),
     }
 }
 
@@ -113,11 +157,11 @@ fn handle_key_sequence_for_search_page(
         }
     }
 
-    let command = match config::get_config()
+    let found_keymap = match config::get_config()
         .keymap_config
-        .find_command_from_key_sequence(key_sequence)
+        .find_command_or_action_from_key_sequence(key_sequence)
     {
-        Some(command) => command,
+        Some(found) => found,
         None => return Ok(false),
     };
 
@@ -127,28 +171,61 @@ fn handle_key_sequence_for_search_page(
     match focus_state {
         SearchFocusState::Input => anyhow::bail!("user's search input should be handled before"),
         SearchFocusState::Tracks => {
-            let tracks = search_results
-                .map(|s| s.tracks.iter().collect())
-                .unwrap_or_default();
-            window::handle_command_for_track_list_window(command, client_pub, tracks, &data, ui)
+            let tracks = match search_results {
+                Some(s) => s.tracks.iter().collect(),
+                None => Vec::new(),
+            };
+
+            match found_keymap {
+                CommandOrAction::Command(command) => window::handle_command_for_track_list_window(
+                    command, client_pub, tracks, &data, ui,
+                ),
+                CommandOrAction::Action(action) => {
+                    window::handle_action_for_selected_item(action, tracks, &data, ui, client_pub)
+                }
+            }
         }
         SearchFocusState::Artists => {
             let artists = search_results
                 .map(|s| s.artists.iter().collect())
                 .unwrap_or_default();
-            window::handle_command_for_artist_list_window(command, artists, &data, ui)
+
+            match found_keymap {
+                CommandOrAction::Command(command) => {
+                    window::handle_command_for_artist_list_window(command, artists, &data, ui)
+                }
+                CommandOrAction::Action(action) => {
+                    window::handle_action_for_selected_item(action, artists, &data, ui, client_pub)
+                }
+            }
         }
         SearchFocusState::Albums => {
             let albums = search_results
                 .map(|s| s.albums.iter().collect())
                 .unwrap_or_default();
-            window::handle_command_for_album_list_window(command, albums, &data, ui, client_pub)
+
+            match found_keymap {
+                CommandOrAction::Command(command) => window::handle_command_for_album_list_window(
+                    command, albums, &data, ui, client_pub,
+                ),
+                CommandOrAction::Action(action) => {
+                    window::handle_action_for_selected_item(action, albums, &data, ui, client_pub)
+                }
+            }
         }
         SearchFocusState::Playlists => {
             let playlists = search_results
                 .map(|s| s.playlists.iter().collect())
                 .unwrap_or_default();
-            window::handle_command_for_playlist_list_window(command, playlists, &data, ui)
+
+            match found_keymap {
+                CommandOrAction::Command(command) => {
+                    window::handle_command_for_playlist_list_window(command, playlists, &data, ui)
+                }
+                CommandOrAction::Action(action) => window::handle_action_for_selected_item(
+                    action, playlists, &data, ui, client_pub,
+                ),
+            }
         }
     }
 }
@@ -168,6 +245,44 @@ fn handle_command_for_context_page(
     }
 }
 
+fn handle_action_for_browse_page(
+    action: Action,
+    client_pub: &flume::Sender<ClientRequest>,
+    ui: &mut UIStateGuard,
+    state: &SharedState,
+) -> Result<bool> {
+    let data = state.data.read();
+
+    match ui.current_page() {
+        PageState::Browse { state } => match state {
+            BrowsePageUIState::CategoryPlaylistList { category, .. } => {
+                let playlists = match data.browse.category_playlists.get(&category.id) {
+                    Some(v) => v,
+                    None => return Ok(false),
+                };
+
+                let page_state = ui.current_page_mut();
+                let selected = page_state.selected().unwrap_or_default();
+                if selected >= playlists.len() {
+                    return Ok(false);
+                }
+
+                handle_action_in_context(
+                    action,
+                    playlists[selected].clone().into(),
+                    client_pub,
+                    &data,
+                    ui,
+                )?;
+
+                Ok(true)
+            }
+            _ => Ok(false),
+        },
+        _ => anyhow::bail!("expect a browse page state"),
+    }
+}
+
 fn handle_command_for_browse_page(
     command: Command,
     client_pub: &flume::Sender<ClientRequest>,
diff --git a/spotify_player/src/event/popup.rs b/spotify_player/src/event/popup.rs
index f578c831..b5b68205 100644
--- a/spotify_player/src/event/popup.rs
+++ b/spotify_player/src/event/popup.rs
@@ -1,8 +1,5 @@
 use super::*;
-use crate::command::{
-    construct_album_actions, construct_artist_actions, AlbumAction, ArtistAction, PlaylistAction,
-    TrackAction,
-};
+use crate::command::construct_artist_actions;
 use anyhow::Context;
 
 pub fn handle_key_sequence_for_popup(
@@ -403,12 +400,6 @@ fn handle_command_for_list_popup(
     Ok(true)
 }
 
-fn execute_copy_command(text: String) -> Result<()> {
-    CLIPBOARD_PROVIDER
-        .get_or_init(|| get_clipboard_provider())
-        .set_contents(text)
-}
-
 fn handle_key_sequence_for_action_list_popup(
     n_actions: usize,
     key_sequence: &KeySequence,
@@ -451,7 +442,7 @@ fn handle_key_sequence_for_action_list_popup(
 }
 
 /// Handle the `n`-th action in an action list popup
-fn handle_item_action(
+pub fn handle_item_action(
     n: usize,
     client_pub: &flume::Sender<ClientRequest>,
     state: &SharedState,
@@ -462,188 +453,20 @@ fn handle_item_action(
         _ => return Ok(()),
     };
 
+    let data = state.data.read();
+
     match item {
-        ActionListItem::Track(track, actions) => match actions[n] {
-            TrackAction::GoToAlbum => {
-                if let Some(album) = track.album {
-                    let uri = album.id.uri();
-                    let context_id = ContextId::Album(
-                        AlbumId::from_uri(&crate::utils::parse_uri(&uri))?.into_static(),
-                    );
-                    ui.new_page(PageState::Context {
-                        id: None,
-                        context_page_type: ContextPageType::Browsing(context_id),
-                        state: None,
-                    });
-                }
-            }
-            TrackAction::GoToArtist => {
-                ui.popup = Some(PopupState::ArtistList(
-                    ArtistPopupAction::Browse,
-                    track.artists,
-                    new_list_state(),
-                ));
-            }
-            TrackAction::AddToQueue => {
-                client_pub.send(ClientRequest::AddTrackToQueue(track.id))?;
-                ui.popup = None;
-            }
-            TrackAction::CopyTrackLink => {
-                let track_url = format!("https://open.spotify.com/track/{}", track.id.id());
-                execute_copy_command(track_url)?;
-                ui.popup = None;
-            }
-            TrackAction::AddToPlaylist => {
-                client_pub.send(ClientRequest::GetUserPlaylists)?;
-                ui.popup = Some(PopupState::UserPlaylistList(
-                    PlaylistPopupAction::AddTrack(track.id),
-                    new_list_state(),
-                ));
-            }
-            TrackAction::AddToLikedTracks => {
-                client_pub.send(ClientRequest::AddToLibrary(Item::Track(track)))?;
-                ui.popup = None;
-            }
-            TrackAction::GoToTrackRadio => {
-                let uri = track.id.uri();
-                let name = track.name;
-                ui.new_radio_page(&uri);
-                client_pub.send(ClientRequest::GetRadioTracks {
-                    seed_uri: uri,
-                    seed_name: name,
-                })?;
-            }
-            TrackAction::ShowActionsOnArtist => {
-                ui.popup = Some(PopupState::ArtistList(
-                    ArtistPopupAction::ShowActions,
-                    track.artists,
-                    new_list_state(),
-                ));
-            }
-            TrackAction::ShowActionsOnAlbum => {
-                if let Some(album) = track.album {
-                    let actions = {
-                        let data = state.data.read();
-                        construct_album_actions(&album, &data)
-                    };
-                    ui.popup = Some(PopupState::ActionList(
-                        ActionListItem::Album(album, actions),
-                        new_list_state(),
-                    ));
-                }
-            }
-            TrackAction::DeleteFromLikedTracks => {
-                client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Track(track.id)))?;
-                ui.popup = None;
-            }
-            TrackAction::DeleteFromCurrentPlaylist => {
-                if let PageState::Context {
-                    id: Some(ContextId::Playlist(playlist_id)),
-                    ..
-                } = ui.current_page()
-                {
-                    client_pub.send(ClientRequest::DeleteTrackFromPlaylist(
-                        playlist_id.clone_static(),
-                        track.id,
-                    ))?;
-                }
-                ui.popup = None;
-            }
-        },
-        ActionListItem::Album(album, actions) => match actions[n] {
-            AlbumAction::GoToArtist => {
-                ui.popup = Some(PopupState::ArtistList(
-                    ArtistPopupAction::Browse,
-                    album.artists,
-                    new_list_state(),
-                ));
-            }
-            AlbumAction::GoToAlbumRadio => {
-                let uri = album.id.uri();
-                let name = album.name;
-                ui.new_radio_page(&uri);
-                client_pub.send(ClientRequest::GetRadioTracks {
-                    seed_uri: uri,
-                    seed_name: name,
-                })?;
-            }
-            AlbumAction::ShowActionsOnArtist => {
-                ui.popup = Some(PopupState::ArtistList(
-                    ArtistPopupAction::ShowActions,
-                    album.artists,
-                    new_list_state(),
-                ));
-            }
-            AlbumAction::CopyAlbumLink => {
-                let album_url = format!("https://open.spotify.com/album/{}", album.id.id());
-                execute_copy_command(album_url)?;
-                ui.popup = None;
-            }
-            AlbumAction::AddToLibrary => {
-                client_pub.send(ClientRequest::AddToLibrary(Item::Album(album)))?;
-                ui.popup = None;
-            }
-            AlbumAction::DeleteFromLibrary => {
-                client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Album(album.id)))?;
-                ui.popup = None;
-            }
-            AlbumAction::AddToQueue => {
-                client_pub.send(ClientRequest::AddAlbumToQueue(album.id))?;
-                ui.popup = None;
-            }
-        },
-        ActionListItem::Artist(artist, actions) => match actions[n] {
-            ArtistAction::Follow => {
-                client_pub.send(ClientRequest::AddToLibrary(Item::Artist(artist)))?;
-                ui.popup = None;
-            }
-            ArtistAction::GoToArtistRadio => {
-                let uri = artist.id.uri();
-                let name = artist.name;
-                ui.new_radio_page(&uri);
-                client_pub.send(ClientRequest::GetRadioTracks {
-                    seed_uri: uri,
-                    seed_name: name,
-                })?;
-            }
-            ArtistAction::CopyArtistLink => {
-                let artist_url = format!("https://open.spotify.com/artist/{}", artist.id.id());
-                execute_copy_command(artist_url)?;
-                ui.popup = None;
-            }
-            ArtistAction::Unfollow => {
-                client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Artist(artist.id)))?;
-                ui.popup = None;
-            }
-        },
-        ActionListItem::Playlist(playlist, actions) => match actions[n] {
-            PlaylistAction::AddToLibrary => {
-                client_pub.send(ClientRequest::AddToLibrary(Item::Playlist(playlist)))?;
-                ui.popup = None;
-            }
-            PlaylistAction::GoToPlaylistRadio => {
-                let uri = playlist.id.uri();
-                let name = playlist.name;
-                ui.new_radio_page(&uri);
-                client_pub.send(ClientRequest::GetRadioTracks {
-                    seed_uri: uri,
-                    seed_name: name,
-                })?;
-            }
-            PlaylistAction::CopyPlaylistLink => {
-                let playlist_url =
-                    format!("https://open.spotify.com/playlist/{}", playlist.id.id());
-                execute_copy_command(playlist_url)?;
-                ui.popup = None;
-            }
-            PlaylistAction::DeleteFromLibrary => {
-                client_pub.send(ClientRequest::DeleteFromLibrary(ItemId::Playlist(
-                    playlist.id,
-                )))?;
-                ui.popup = None;
-            }
-        },
+        ActionListItem::Track(track, actions) => {
+            handle_action_in_context(actions[n], track.into(), client_pub, &data, ui)
+        }
+        ActionListItem::Album(album, actions) => {
+            handle_action_in_context(actions[n], album.into(), client_pub, &data, ui)
+        }
+        ActionListItem::Artist(artist, actions) => {
+            handle_action_in_context(actions[n], artist.into(), client_pub, &data, ui)
+        }
+        ActionListItem::Playlist(playlist, actions) => {
+            handle_action_in_context(actions[n], playlist.into(), client_pub, &data, ui)
+        }
     }
-
-    Ok(())
 }
diff --git a/spotify_player/src/event/window.rs b/spotify_player/src/event/window.rs
index 85a5bcd2..5596ad19 100644
--- a/spotify_player/src/event/window.rs
+++ b/spotify_player/src/event/window.rs
@@ -1,13 +1,105 @@
 use super::page::handle_navigation_command;
 use super::*;
 use crate::{
-    command::{
-        construct_album_actions, construct_artist_actions, construct_playlist_actions, TrackAction,
-    },
+    command::{construct_album_actions, construct_artist_actions, construct_playlist_actions},
     state::UIStateGuard,
 };
+use command::Action;
 use rand::Rng;
 
+pub fn handle_action_for_focused_context_page(
+    action: Action,
+    client_pub: &flume::Sender<ClientRequest>,
+    ui: &mut UIStateGuard,
+    state: &SharedState,
+) -> Result<bool> {
+    let context_id = match ui.current_page() {
+        PageState::Context { id: Some(id), .. } => id,
+        _ => return Ok(false),
+    };
+
+    let data = state.data.read();
+    match data.caches.context.get(&context_id.uri()) {
+        Some(Context::Artist {
+            top_tracks,
+            albums,
+            related_artists,
+            ..
+        }) => {
+            let focus_state = match ui.current_page() {
+                PageState::Context {
+                    state: Some(ContextPageUIState::Artist { focus, .. }),
+                    ..
+                } => focus,
+                _ => return Ok(false),
+            };
+
+            match focus_state {
+                ArtistFocusState::Albums => handle_action_for_selected_item(
+                    action,
+                    ui.search_filtered_items(albums),
+                    &data,
+                    ui,
+                    client_pub,
+                ),
+                ArtistFocusState::RelatedArtists => handle_action_for_selected_item(
+                    action,
+                    ui.search_filtered_items(related_artists),
+                    &data,
+                    ui,
+                    client_pub,
+                ),
+                ArtistFocusState::TopTracks => handle_action_for_selected_item(
+                    action,
+                    ui.search_filtered_items(top_tracks),
+                    &data,
+                    ui,
+                    client_pub,
+                ),
+            }
+        }
+        Some(Context::Album { tracks, .. }) => handle_action_for_selected_item(
+            action,
+            ui.search_filtered_items(tracks),
+            &data,
+            ui,
+            client_pub,
+        ),
+        Some(Context::Tracks { tracks, .. }) => handle_action_for_selected_item(
+            action,
+            ui.search_filtered_items(tracks),
+            &data,
+            ui,
+            client_pub,
+        ),
+        Some(Context::Playlist { tracks, .. }) => handle_action_for_selected_item(
+            action,
+            ui.search_filtered_items(tracks),
+            &data,
+            ui,
+            client_pub,
+        ),
+        None => Ok(false),
+    }
+}
+
+pub fn handle_action_for_selected_item<T: Into<ActionContext> + Clone>(
+    action: Action,
+    items: Vec<&T>,
+    data: &DataReadGuard,
+    ui: &mut UIStateGuard,
+    client_pub: &flume::Sender<ClientRequest>,
+) -> Result<bool> {
+    let id = ui.current_page_mut().selected().unwrap_or_default();
+    if id >= items.len() {
+        return Ok(false);
+    }
+
+    handle_action_in_context(action, items[id].clone().into(), client_pub, data, ui)?;
+
+    Ok(true)
+}
+
 /// Handle a command for the currently focused context window
 ///
 /// The function will need to determine the focused window then
@@ -155,7 +247,7 @@ fn handle_playlist_modify_command(
         }
         Command::ShowActionsOnSelectedItem => {
             let mut actions = command::construct_track_actions(tracks[id], data);
-            actions.push(TrackAction::DeleteFromCurrentPlaylist);
+            actions.push(Action::DeleteFromPlaylist);
             ui.popup = Some(PopupState::ActionList(
                 ActionListItem::Track(tracks[id].clone(), actions),
                 new_list_state(),
diff --git a/spotify_player/src/state/ui/popup.rs b/spotify_player/src/state/ui/popup.rs
index f81ccc53..5aeba2e2 100644
--- a/spotify_player/src/state/ui/popup.rs
+++ b/spotify_player/src/state/ui/popup.rs
@@ -28,10 +28,10 @@ pub enum PopupState {
 
 #[derive(Debug, Clone)]
 pub enum ActionListItem {
-    Track(Track, Vec<command::TrackAction>),
-    Artist(Artist, Vec<command::ArtistAction>),
-    Album(Album, Vec<command::AlbumAction>),
-    Playlist(Playlist, Vec<command::PlaylistAction>),
+    Track(Track, Vec<command::Action>),
+    Artist(Artist, Vec<command::Action>),
+    Album(Album, Vec<command::Action>),
+    Playlist(Playlist, Vec<command::Action>),
 }
 
 /// An action on an item in a playlist popup list


Test patch: