From 28fb0346cf4b716c0866d5a2253bd61b6e64199c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Birath?= Date: Sun, 23 Mar 2025 15:58:43 +0100 Subject: [PATCH 1/2] Move volume API calls to background thread and batch volume changes Blocking API calls on the main thread could cause key presses to not be registered by the application when done in quick succession, such as when a scroll wheel was used. Batching volume increments done in quick succession (100ms by default) reduces the amount of API calls, avoiding throttling errors from Spotify. The batching delay is configured through a new config value, `batch_delay_ms`. Setting this to `0` disables the batching. --- CMakePresets.json | 23 ++++++++++ source/Client.h | 3 +- source/Config.cpp | 89 +++++++++++++++++++++++-------------- source/Config.h | 6 ++- source/VolumeController.cpp | 77 ++++++++++++++++++++++++++------ source/VolumeController.h | 47 +++++++++++++++++++- source/data_types.h | 6 ++- vcpkg.json | 5 ++- 8 files changed, 201 insertions(+), 55 deletions(-) diff --git a/CMakePresets.json b/CMakePresets.json index ad0d824..176cf23 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -65,6 +65,16 @@ "CMAKE_SHARED_LINKER_FLAGS": "-Wl,--allow-shlib-undefined,--as-needed,-z,noexecstack,-z,relro,-z,now,-z,nodlopen" } }, + { + "name": "flags-windows-gcc-clang", + "description": "These flags are supported by both GCC and Clang", + "hidden": true, + "cacheVariables": { + "CMAKE_CXX_FLAGS": "-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 -D_GLIBCXX_ASSERTIONS=1 -fstack-protector-strong -fcf-protection=full -fstack-clash-protection -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Wcast-qual -Wformat=2 -Wundef -Werror=float-equal -Wshadow -Wcast-align -Wunused -Wnull-dereference -Wdouble-promotion -Wimplicit-fallthrough -Wextra-semi -Woverloaded-virtual -Wnon-virtual-dtor -Wold-style-cast", + "CMAKE_EXE_LINKER_FLAGS": "-Wl,--allow-shlib-undefined,--as-needed", + "CMAKE_SHARED_LINKER_FLAGS": "-Wl,--allow-shlib-undefined,--as-needed" + } + }, { "name": "flags-appleclang", "hidden": true, @@ -106,6 +116,19 @@ "architecture": "x64", "hidden": true }, + { + "name": "ci-win-mingw64", + "inherits": ["flags-windows-gcc-clang", "ci-std"], + "generator": "Ninja", + "hidden": true + }, + { + "name": "vcpkg-mingw64-static", + "hidden": true, + "cacheVariables": { + "VCPKG_TARGET_TRIPLET": "x64-mingw-static" + } + }, { "name": "coverage-linux", "binaryDir": "${sourceDir}/build/coverage", diff --git a/source/Client.h b/source/Client.h index 91c7649..ec23b66 100644 --- a/source/Client.h +++ b/source/Client.h @@ -2,8 +2,8 @@ #include -#include #include +#include #include #include "Config.h" @@ -21,7 +21,6 @@ class Client [[nodiscard]] cpr::Response api_request(const std::string_view endpoint); [[nodiscard]] cpr::Response put_api_request(const std::string_view endpoint, const cpr::Payload& parameters); - // Makes a request to the device endpoint [[nodiscard]] std::optional get_devices(); diff --git a/source/Config.cpp b/source/Config.cpp index 15373fc..d9bf25a 100644 --- a/source/Config.cpp +++ b/source/Config.cpp @@ -1,16 +1,33 @@ +#include +#include #include #include +#include #include +#include #include "Config.h" #include -constexpr std::string_view VOLUME_INCREMENT_KEY = "volume_increment"; +#include "data_types.h" namespace spotify_volume_controller { +constexpr std::string_view client_id_key = "client_id"; +constexpr std::string_view client_secret_key = "client_secret"; +constexpr std::string_view hide_window_key = "hide_window"; +constexpr std::string_view redirect_url_key = "redirect_url"; +constexpr std::string_view print_keys_key = "print_keys"; +constexpr std::string_view volume_increment_key = "volume_increment"; +constexpr std::string_view volume_up_key = "volume_up"; +constexpr std::string_view volume_down_key = "volume_down"; +constexpr std::string_view batch_delay_key = "batch_delay_ms"; + +constexpr std::chrono::milliseconds default_batch_delay {100}; +constexpr uint32_t default_volume_increment = 1; + Config::Config() { parse_config_file("config.json"); @@ -40,35 +57,36 @@ void Config::parse_config_file(const std::filesystem::path& path) std::string input {}; get_user_input("Please enter your spotify client id", input, true); - config["client_id"] = input; + config[client_id_key] = input; get_user_input("Please enter your spotify client secret", input, true); - config["client_secret"] = input; + config[client_secret_key] = input; get_user_input(fmt::format("Enter callback url, or leave empty for default ({}).", Config::DEFAULT_CALLBACK_URL), input); if (input.empty()) { - config["redirect_url"] = Config::DEFAULT_CALLBACK_URL; + config[redirect_url_key] = Config::DEFAULT_CALLBACK_URL; } else { - config["redirect_url"] = input; + config[redirect_url_key] = input; } get_user_input("Enter volume up virtual keycode as a number, or leave empty for default", input); if (input.empty()) { - config["volume_up"] = "default"; + config[volume_up_key] = "default"; } else { - config["volume_up"] = input; + config[volume_up_key] = input; } get_user_input("Enter volume down virtual keycode as a number, or leave empty for default", input); if (input.empty()) { - config["volume_down"] = "default"; + config[volume_down_key] = "default"; } else { - config["volume_down"] = input; + config[volume_down_key] = input; } - config["print_keys"] = false; - config["hide_window"] = false; + config[print_keys_key] = false; + config[hide_window_key] = false; - config[VOLUME_INCREMENT_KEY.data()] = 1; + config[volume_increment_key] = default_volume_increment; + config[batch_delay_key] = default_batch_delay.count(); new_config_file << config; new_config_file.close(); @@ -76,62 +94,62 @@ void Config::parse_config_file(const std::filesystem::path& path) std::string Config::get_client_id() const { - return config.at("client_id").template get(); + return config.at(client_id_key).template get(); } std::string Config::get_client_secret() const { - return config.at("client_secret").template get(); + return config.at(client_secret_key).template get(); } std::string Config::get_redirect_url() const { - return config.at("redirect_url").template get(); + return config.at(redirect_url_key).template get(); } bool Config::should_print_keys() const { - return config.at("print_keys").template get(); + return config.at(print_keys_key).template get(); } keycode Config::get_volume_up() const { - if (!config.contains("volume_up")) { - throw std::runtime_error("Missing volume_up config"); + if (!config.contains(volume_up_key)) { + throw std::runtime_error(std::format("Missing {} config", volume_up_key)); } - json v_up = config.at("volume_up"); + json v_up = config.at(volume_down_key); if (!v_up.is_number_integer()) { - throw std::runtime_error("volume_up config is not a valid keycode"); + throw std::runtime_error(fmt::format("{} config is not a valid keycode", volume_up_key)); } return v_up.template get(); } keycode Config::get_volume_down() const { - if (!config.contains("volume_down")) { - throw std::runtime_error("Missing volume_down config"); + if (!config.contains(volume_down_key)) { + throw std::runtime_error(fmt::format("Missing {} config", volume_down_key)); } - json v_down = config.at("volume_down"); + json v_down = config.at(volume_down_key); if (!v_down.is_number_integer()) { - throw std::runtime_error("volume_down config is not a valid keycode"); + throw std::runtime_error(std::format("{} config is not a valid keycode", volume_down_key)); } return v_down.template get(); } bool Config::is_default_down() const { - if (!config.contains("volume_down")) { + if (!config.contains(volume_down_key)) { return false; } - json v_down = config.at("volume_down"); + json v_down = config.at(volume_down_key); return v_down.is_string() && v_down.template get() == "default"; } bool Config::is_default_up() const { - if (!config.contains("volume_up")) { + if (!config.contains(volume_down_key)) { return false; } - json v_up = config.at("volume_up"); + json v_up = config.at(volume_down_key); return v_up.is_string() && v_up.template get() == "default"; } @@ -142,11 +160,11 @@ bool Config::is_valid() const bool Config::hide_window() const { - if (!config.contains("hide_window")) { + if (!config.contains(hide_window_key)) { return false; } - return config.at("hide_window").template get(); + return config.at(hide_window_key).template get(); } [[nodiscard]] std::filesystem::path Config::config_directory() const @@ -156,11 +174,16 @@ bool Config::hide_window() const volume Config::volume_increment() const { - if (!config.contains(VOLUME_INCREMENT_KEY.data())) { - return 1; + if (!config.contains(volume_increment_key.data())) { + return default_volume_increment; } - return config.at(VOLUME_INCREMENT_KEY.data()).template get(); + return config.at(volume_increment_key.data()).template get(); +} + +std::chrono::milliseconds Config::batch_delay() const +{ + return std::chrono::milliseconds(config.value(batch_delay_key, default_batch_delay.count())); } void Config::get_user_input(const std::string_view prompt, std::string& input, bool not_empty) const diff --git a/source/Config.h b/source/Config.h index 087a458..e9920eb 100644 --- a/source/Config.h +++ b/source/Config.h @@ -1,14 +1,15 @@ #pragma once +#include #include #include #include "data_types.h" -// for convenience -using json = nlohmann::json; namespace spotify_volume_controller { +// for convenience +using json = nlohmann::json; class Config { @@ -24,6 +25,7 @@ class Config keycode get_volume_up() const; keycode get_volume_down() const; volume volume_increment() const; + std::chrono::milliseconds batch_delay() const; bool is_default_down() const; bool is_default_up() const; diff --git a/source/VolumeController.cpp b/source/VolumeController.cpp index 9187d76..2aacd51 100644 --- a/source/VolumeController.cpp +++ b/source/VolumeController.cpp @@ -1,20 +1,32 @@ +#include +#include +#include #include +#include #include "VolumeController.h" +#include #include +#include +#include "Client.h" +#include "Config.h" +#include "data_types.h" #include "key_hooks.h" -// for convenience -using json = nlohmann::json; + namespace spotify_volume_controller { +// for convenience +using json = nlohmann::json; +using namespace std::chrono_literals; VolumeController::VolumeController(const Config& config, Client& client) : m_config(config) , m_client(client) , m_volume_up_keycode(config.is_default_up() ? VK_VOLUME_UP : config.get_volume_up()) , m_volume_down_keycode(config.is_default_down() ? VK_VOLUME_DOWN : config.get_volume_down()) + , m_client_thread(std::thread(&VolumeController::set_volume_loop, this)) { } @@ -36,35 +48,56 @@ void VolumeController::set_desktop_device() void VolumeController::decrease_volume() { - set_volume(m_volume - m_config.volume_increment()); + { + std::unique_lock lk(m_volume_mutex); + // Assume that the API call will succeed + m_volume = m_volume - m_config.volume_increment(); + m_volume_queue.push(m_volume); + } + if (m_config.batch_delay() > 0ms) { + m_notify_timer.start([this]() { m_volume_cv.notify_one(); }, m_config.batch_delay()); + } else { + m_volume_cv.notify_one(); + } } void VolumeController::increase_volume() { - set_volume(m_volume + m_config.volume_increment()); + { + std::unique_lock lk(m_volume_mutex); + // Assume that the API call will succeed + m_volume = m_volume + m_config.volume_increment(); + m_volume_queue.push(m_volume); + } + if (m_config.batch_delay() > 0ms) { + m_notify_timer.start([this]() { m_volume_cv.notify_one(); }, m_config.batch_delay()); + } else { + m_volume_cv.notify_one(); + } } -void VolumeController::set_volume(const volume to_volume) -{ - volume new_volume = to_volume; +void VolumeController::set_volume_to_desktop_device_volume() { if (!m_desktop_device_id.has_value()) { set_desktop_device(); - if (m_desktop_device_id.has_value()) { - new_volume = m_client.get_device_volume(m_desktop_device_id.value()).value_or(volume(0)); - } } + if (m_desktop_device_id.has_value()) { + m_volume = m_client.get_device_volume(m_desktop_device_id.value()).value_or(volume(0)); + } +} +void VolumeController::set_volume(const volume new_volume) +{ cpr::Response result = m_client.set_volume(new_volume); if (result.status_code == cpr::status::HTTP_NO_CONTENT) { - m_volume = new_volume; return; - } else if ((result.status_code == cpr::status::HTTP_NOT_FOUND || result.status_code == cpr::status::HTTP_FORBIDDEN || cpr::status::HTTP_LENGTH_REQUIRED) - && m_desktop_device_id.has_value()) + } + if ((result.status_code == cpr::status::HTTP_NOT_FOUND || result.status_code == cpr::status::HTTP_FORBIDDEN + || result.status_code == cpr::status::HTTP_LENGTH_REQUIRED) + && m_desktop_device_id.has_value()) { result = m_client.set_device_volume(new_volume, m_desktop_device_id.value()); } if (result.status_code == cpr::status::HTTP_NO_CONTENT) { - m_volume = new_volume; return; } m_client.print_error_message(result); @@ -72,6 +105,7 @@ void VolumeController::set_volume(const volume to_volume) void VolumeController::start() { + set_volume_to_desktop_device_volume(); key_hooks::start_volume_hook(this); } @@ -89,4 +123,19 @@ void VolumeController::print_keys() { return m_volume_down_keycode; } + +void VolumeController::set_volume_loop() +{ + while (true) { + std::unique_lock lk(m_volume_mutex); + m_volume_cv.wait(lk, [&] { return !m_volume_queue.empty(); }); + volume new_volume {0}; + while (!m_volume_queue.empty()) { + new_volume = m_volume_queue.front(); + m_volume_queue.pop(); + } + set_volume(new_volume); + } +} + } // namespace spotify_volume_controller diff --git a/source/VolumeController.h b/source/VolumeController.h index a8e9328..2273356 100644 --- a/source/VolumeController.h +++ b/source/VolumeController.h @@ -1,13 +1,50 @@ #pragma once +#include +#include +#include +#include +#include +#include + #include "Client.h" #include "Config.h" namespace spotify_volume_controller { +class timer +{ +public: + + [[nodiscard]] bool is_running() const { return m_is_running; } + void start(const std::function& callback, std::chrono::milliseconds delay) + { + if (m_is_running) { + return; + } + m_is_running = true; + m_timer_thread = std::jthread + { + [callback, delay, this]() + { + std::this_thread::sleep_for(delay); + callback(); + m_is_running = false; + } + }; + } +private: + std::jthread m_timer_thread; + bool m_is_running = false; +}; + class VolumeController { public: + VolumeController(const VolumeController&) = delete; + VolumeController(VolumeController&&) = delete; + VolumeController& operator=(const VolumeController&) = delete; + VolumeController& operator=(VolumeController&&) = delete; VolumeController(const Config& config, Client& client); ~VolumeController(); @@ -23,13 +60,21 @@ class VolumeController void print_keys(); private: - void set_volume(const volume to_volume); + void set_volume(volume to_volume); + void set_volume_loop(); + void set_volume_to_desktop_device_volume(); volume m_volume = 0; const Config m_config; Client m_client; const keycode m_volume_up_keycode; const keycode m_volume_down_keycode; std::optional m_desktop_device_id = {}; + std::thread m_client_thread; + std::jthread m_notify_timer_thread; + std::mutex m_volume_mutex; + std::condition_variable m_volume_cv; + std::queue m_volume_queue; + timer m_notify_timer{}; }; } // namespace spotify_volume_controller diff --git a/source/data_types.h b/source/data_types.h index 9a7d4b0..60e9f62 100644 --- a/source/data_types.h +++ b/source/data_types.h @@ -8,6 +8,8 @@ namespace spotify_volume_controller using keycode = unsigned int; using volume_t = unsigned int; +constexpr volume_t max_volume{100}; + struct volume { volume(volume_t v) @@ -24,12 +26,12 @@ struct volume [[nodiscard]] volume_t operator+(const volume other) const { - return m_volume + other.m_volume > 100 ? 100 : m_volume + other.m_volume; + return m_volume + other.m_volume > max_volume ? max_volume : m_volume + other.m_volume; } volume& operator++() { - if (m_volume < 100) { + if (m_volume < max_volume) { m_volume++; } return *this; diff --git a/vcpkg.json b/vcpkg.json index 6c4d0f9..c79b012 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -6,7 +6,10 @@ "name": "fmt", "version>=": "10.1.0" }, - + { + "name": "curl", + "version>=": "8.12.1" + }, "cpr", "argparse", "nlohmann-json", From 66b482afdb45a7525f4440eda81113be9f3bb103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Birath?= Date: Sun, 23 Mar 2025 16:06:51 +0100 Subject: [PATCH 2/2] Bump VCPKG version --- .github/workflows/msbuild.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/msbuild.yml b/.github/workflows/msbuild.yml index f1e5975..468b900 100644 --- a/.github/workflows/msbuild.yml +++ b/.github/workflows/msbuild.yml @@ -15,7 +15,7 @@ on: env: # Path to the solution file relative to the root of the project. SOLUTION_FILE_PATH: spotify-volume-controller-cpp.sln - VCPKG_COMMIT: "16ee2ecb31788c336ace8bb14c21801efb6836e4" + VCPKG_COMMIT: "b02e341c927f16d991edbd915d8ea43eac52096c" # Configuration type to build. # You can convert this to a build matrix if you need coverage of multiple configuration types.