From 05057c9441dbf3972d49f9143b90213a43a01a00 Mon Sep 17 00:00:00 2001 From: Pyxellator Date: Tue, 29 Sep 2026 22:46:44 +0200 Subject: [PATCH] launcher: configure and preview DualSense lightbar color (#912) launcher: configure and preview controller lightbar color Add an optional lightbar color to launcher settings and pass it to the emulator with --controller-color. Preview changes on RGB gamepads, handle controller hotplug events, and restore the saved color when settings dialogs close. Release launcher preview handles when starting a game. Co-authored-by: m66401970-eng --- src/common/emulatorConfig.cpp | 4 + src/common/emulatorConfig.h | 5 ++ .../forms/configuration_edit_dialog.ui | 21 +++++ src/launcher/include/configuration.h | 6 ++ .../include/configurationEditDialog.h | 3 + .../include/configurationListWidget.h | 4 + src/launcher/include/controllerLightbar.h | 29 +++++++ src/launcher/src/configurationEditDialog.cpp | 35 ++++++++ src/launcher/src/configurationListWidget.cpp | 7 ++ src/launcher/src/controllerLightbar.cpp | 84 +++++++++++++++++++ src/launcher/src/mainDialog.cpp | 35 ++++++-- src/libs/controller.cpp | 8 ++ src/main.cpp | 22 +++++ 13 files changed, 255 insertions(+), 8 deletions(-) create mode 100644 src/launcher/include/controllerLightbar.h create mode 100644 src/launcher/src/controllerLightbar.cpp diff --git a/src/common/emulatorConfig.cpp b/src/common/emulatorConfig.cpp index bebf97fd2..5399a6c53 100644 --- a/src/common/emulatorConfig.cpp +++ b/src/common/emulatorConfig.cpp @@ -47,6 +47,10 @@ const std::string& GetAudioInputDevice() { return g_config->audio_input_device; } +const std::optional& GetControllerColor() { + return g_config->controller_color; +} + PresentMode GetPresentMode() { return g_config->present_mode; } diff --git a/src/common/emulatorConfig.h b/src/common/emulatorConfig.h index de345a6ef..2d369626f 100644 --- a/src/common/emulatorConfig.h +++ b/src/common/emulatorConfig.h @@ -4,7 +4,9 @@ #include "common/common.h" #include +#include #include +#include #include #include @@ -26,6 +28,7 @@ enum class LogDirection { Silent, Console, File }; enum class PresentMode { Fifo, Mailbox, Immediate }; using Keymap = std::vector; +using ControllerColor = std::array; constexpr uint32_t DEFAULT_CONSOLE_LANGUAGE = 1; constexpr uint32_t MAX_CONSOLE_LANGUAGE = 29; @@ -44,6 +47,7 @@ struct ConfigOptions { std::string user_name = "Kyty"; int32_t user_id = DEFAULT_USER_ID; std::string audio_input_device; + std::optional controller_color; PresentMode present_mode = PresentMode::Mailbox; int32_t gpu_index = -1; bool fullscreen_enabled = false; @@ -81,6 +85,7 @@ uint32_t GetScreenHeight(); const std::string& GetUserName(); int32_t GetUserId(); const std::string& GetAudioInputDevice(); +const std::optional& GetControllerColor(); PresentMode GetPresentMode(); int32_t GetGpuIndex(); bool FullscreenEnabled(); diff --git a/src/launcher/forms/configuration_edit_dialog.ui b/src/launcher/forms/configuration_edit_dialog.ui index e0ad795ff..660667f6f 100644 --- a/src/launcher/forms/configuration_edit_dialog.ui +++ b/src/launcher/forms/configuration_edit_dialog.ui @@ -72,6 +72,27 @@ Language + + + DualSense lightbar: + + + + + + + Choose and preview a fixed LED color. Bluetooth DualSense may take several seconds to update. + Custom + + + + + Let the game control the lightbar + Default + + + + diff --git a/src/launcher/include/configuration.h b/src/launcher/include/configuration.h index 3d1bbb9a7..411d1a226 100644 --- a/src/launcher/include/configuration.h +++ b/src/launcher/include/configuration.h @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -88,6 +89,7 @@ public: QString user_name = "Kyty"; int user_id = Config::DEFAULT_USER_ID; QString audio_input_device; + QString controller_color; PresentMode present_mode = PresentMode::Mailbox; int gpu_index = -1; bool fullscreen_enabled = false; @@ -119,6 +121,7 @@ public: user_name = other.user_name; user_id = other.user_id; audio_input_device = other.audio_input_device; + controller_color = other.controller_color; present_mode = other.present_mode; gpu_index = other.gpu_index; fullscreen_enabled = other.fullscreen_enabled; @@ -165,6 +168,7 @@ public: KYTY_CFG_SET(user_name); KYTY_CFG_SET(user_id); KYTY_CFG_SET(audio_input_device); + KYTY_CFG_SET(controller_color); KYTY_CFG_SET(present_mode); KYTY_CFG_SET(gpu_index); KYTY_CFG_SET(fullscreen_enabled); @@ -204,6 +208,8 @@ public: ? saved_user_id : Config::DEFAULT_USER_ID; audio_input_device = s->value("audio_input_device", audio_input_device).toString(); + const QColor color(s->value("controller_color", controller_color).toString()); + controller_color = color.isValid() ? color.name(QColor::HexRgb) : QString {}; KYTY_CFG_GET(present_mode); gpu_index = s->value("gpu_index", -1).toInt(); if (EnumToText(present_mode).isEmpty()) { diff --git a/src/launcher/include/configurationEditDialog.h b/src/launcher/include/configurationEditDialog.h index 2ab27f488..25adc8be6 100644 --- a/src/launcher/include/configurationEditDialog.h +++ b/src/launcher/include/configurationEditDialog.h @@ -31,6 +31,9 @@ public: void SetGameDirectories(const QStringList& dirs); [[nodiscard]] QStringList GetGameDirectories() const; +signals: + void PreviewControllerColor(const QString& color); + private: Ui::ConfigurationEditDialog* m_ui = nullptr; Configuration& m_info; diff --git a/src/launcher/include/configurationListWidget.h b/src/launcher/include/configurationListWidget.h index af201b57f..53671fc47 100644 --- a/src/launcher/include/configurationListWidget.h +++ b/src/launcher/include/configurationListWidget.h @@ -33,6 +33,9 @@ public: ConfigurationItem* GetSelectedItem() { return m_selected_item; } [[nodiscard]] const QString& GetSettingsFile() const { return m_settings_file; } + [[nodiscard]] const QString& GetGlobalControllerColor() const { + return m_global_info.controller_color; + } [[nodiscard]] std::unique_ptr CreateConfiguration(const ConfigurationItem& item) const; @@ -44,6 +47,7 @@ signals: void Run(); void Select(); + void PreviewControllerColor(const QString& color); protected: void changeEvent(QEvent* event) override; diff --git a/src/launcher/include/controllerLightbar.h b/src/launcher/include/controllerLightbar.h new file mode 100644 index 000000000..84b69f458 --- /dev/null +++ b/src/launcher/include/controllerLightbar.h @@ -0,0 +1,29 @@ +#ifndef CONTROLLER_LIGHTBAR_H +#define CONTROLLER_LIGHTBAR_H + +#include +#include +#include +#include +#include + +#include + +class ControllerLightbar final: public QObject { +public: + explicit ControllerLightbar(QObject* parent = nullptr); + ~ControllerLightbar() override; + + void SetColor(const QString& color); + void Stop(); + +private: + void Refresh(); + void ApplyColor(SDL_Gamepad* gamepad) const; + + QTimer m_timer; + QHash m_gamepads; + QColor m_color; +}; + +#endif // CONTROLLER_LIGHTBAR_H diff --git a/src/launcher/src/configurationEditDialog.cpp b/src/launcher/src/configurationEditDialog.cpp index 2d38dd72c..1dc0fc7c4 100644 --- a/src/launcher/src/configurationEditDialog.cpp +++ b/src/launcher/src/configurationEditDialog.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -40,6 +41,22 @@ constexpr char SETTINGS_CFG_DIALOG[] = "ConfigurationEditDialog"; constexpr char SETTINGS_CFG_LAST_GEOMETRY[] = "geometry"; constexpr int GLOBAL_SETTINGS_GAME_DIRS_MIN_WIDTH = 560; +static void UpdateControllerColorButton(QPushButton* button, const QString& hex) { + const QColor color(hex); + if (!color.isValid()) { + button->setProperty("controllerColor", QString {}); + button->setText(QObject::tr("Custom")); + button->setStyleSheet({}); + return; + } + const auto normalized = color.name(QColor::HexRgb); + button->setProperty("controllerColor", normalized); + button->setText(normalized.toUpper()); + button->setStyleSheet(QStringLiteral("background-color: %1; color: %2;") + .arg(normalized, color.lightness() < 128 ? QStringLiteral("white") + : QStringLiteral("black"))); +} + const QStringList CONSOLE_LANGUAGE_NAMES = { "Japanese", "English (United States)", @@ -110,6 +127,21 @@ ConfigurationEditDialog::ConfigurationEditDialog(Configuration& info, QWidget* p connect(m_ui->ok_button, &QPushButton::clicked, this, &ConfigurationEditDialog::save); connect(m_ui->cancel_button, &QPushButton::clicked, this, &QDialog::reject); connect(m_ui->clear_button, &QPushButton::clicked, this, &ConfigurationEditDialog::clear); + connect(m_ui->button_controller_color, &QPushButton::clicked, this, [this]() { + const auto current = m_ui->button_controller_color->property("controllerColor").toString(); + const auto initial = + current.isEmpty() ? QColor(QStringLiteral("#0070d1")) : QColor(current); + const auto color = QColorDialog::getColor(initial, this, tr("DualSense lightbar color")); + if (color.isValid()) { + const auto hex = color.name(QColor::HexRgb); + UpdateControllerColorButton(m_ui->button_controller_color, hex); + emit PreviewControllerColor(hex); + } + }); + connect(m_ui->button_controller_color_reset, &QPushButton::clicked, this, [this]() { + UpdateControllerColorButton(m_ui->button_controller_color, {}); + emit PreviewControllerColor({}); + }); connect(m_ui->comboBox_shader_log_direction, &QComboBox::currentTextChanged, this, [this](const QString& text) { auto log = TextToEnum(text); @@ -169,6 +201,7 @@ void ConfigurationEditDialog::Init(const Configuration& info) { m_ui->lineEdit_user_name->setMaxLength(static_cast(Config::MAX_USER_NAME_LENGTH)); m_ui->lineEdit_user_name->setText(info.user_name); m_ui->spinBox_user_id->setValue(info.user_id); + UpdateControllerColorButton(m_ui->button_controller_color, info.controller_color); auto* microphone = m_ui->comboBox_audio_input_device; microphone->clear(); microphone->addItem(tr("None"), QString {}); @@ -370,6 +403,7 @@ static void UpdateInfo(Configuration& info, Ui::ConfigurationEditDialog& ui) { info.user_name = ui.lineEdit_user_name->text().trimmed(); info.user_id = ui.spinBox_user_id->value(); info.audio_input_device = ui.comboBox_audio_input_device->currentData().toString(); + info.controller_color = ui.button_controller_color->property("controllerColor").toString(); info.screen_resolution = TextToEnum(ui.comboBox_screen_resolution->currentText()); info.present_mode = @@ -427,6 +461,7 @@ void ConfigurationEditDialog::save() { void ConfigurationEditDialog::clear() { Configuration default_info; Init(default_info); + emit PreviewControllerColor({}); if (m_show_game_dirs) { m_game_dirs_list->clear(); diff --git a/src/launcher/src/configurationListWidget.cpp b/src/launcher/src/configurationListWidget.cpp index 6da50bd09..b8a1a569a 100644 --- a/src/launcher/src/configurationListWidget.cpp +++ b/src/launcher/src/configurationListWidget.cpp @@ -751,6 +751,8 @@ void ConfigurationListWidget::edit_configuration() { auto info = CreateConfiguration(*item); ConfigurationEditDialog dlg(*info, this); dlg.setWindowTitle(tr("Edit game settings")); + connect(&dlg, &ConfigurationEditDialog::PreviewControllerColor, this, + &ConfigurationListWidget::PreviewControllerColor); if (dlg.exec() == QDialog::Accepted) { info->custom_settings = true; @@ -761,6 +763,8 @@ void ConfigurationListWidget::edit_configuration() { WriteSettings(); item->Update(); SelectItem(item); + } else { + emit Select(); } } @@ -788,6 +792,8 @@ void ConfigurationListWidget::edit_global_settings() { ConfigurationEditDialog dlg(info, this); dlg.setWindowTitle(tr("Global settings")); dlg.SetGameDirectories(m_game_dirs); + connect(&dlg, &ConfigurationEditDialog::PreviewControllerColor, this, + &ConfigurationListWidget::PreviewControllerColor); if (dlg.exec() == QDialog::Accepted) { m_global_info.CopyEmulatorSettingsFrom(info); @@ -799,6 +805,7 @@ void ConfigurationListWidget::edit_global_settings() { ScanGameDirectory(); } } + emit Select(); } void ConfigurationListWidget::edit_input_mapping() { diff --git a/src/launcher/src/controllerLightbar.cpp b/src/launcher/src/controllerLightbar.cpp new file mode 100644 index 000000000..807743460 --- /dev/null +++ b/src/launcher/src/controllerLightbar.cpp @@ -0,0 +1,84 @@ +#include "controllerLightbar.h" + +#include + +#include + +ControllerLightbar::ControllerLightbar(QObject* parent): QObject(parent) { + m_timer.setInterval(100); + connect(&m_timer, &QTimer::timeout, this, &ControllerLightbar::Refresh); +} + +ControllerLightbar::~ControllerLightbar() { + Stop(); +} + +void ControllerLightbar::SetColor(const QString& color) { + const QColor parsed(color); + if (!parsed.isValid()) { + Stop(); + return; + } + if (m_color == parsed) { + return; + } + if (!m_timer.isActive()) { + if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) { + qWarning() << "Cannot initialize controller color preview:" << SDL_GetError(); + return; + } + m_timer.start(); + } + m_color = parsed; + for (auto* gamepad: m_gamepads) { + ApplyColor(gamepad); + } + Refresh(); +} + +void ControllerLightbar::Stop() { + if (!m_timer.isActive()) { + return; + } + m_timer.stop(); + for (auto* gamepad: m_gamepads) { + SDL_CloseGamepad(gamepad); + } + m_gamepads.clear(); + SDL_QuitSubSystem(SDL_INIT_GAMEPAD); + m_color = {}; +} + +void ControllerLightbar::Refresh() { + // Bluetooth DualSense may defer an accepted LED command until its connection + // animation ends. Polling events also delivers those deferred commands. + SDL_Event event; + while (SDL_PollEvent(&event)) { + if (event.type == SDL_EVENT_GAMEPAD_ADDED && !m_gamepads.contains(event.gdevice.which)) { + auto* gamepad = SDL_OpenGamepad(event.gdevice.which); + if (gamepad == nullptr) { + qWarning() << "Cannot open controller color preview:" << SDL_GetError(); + continue; + } + if (SDL_GetBooleanProperty(SDL_GetGamepadProperties(gamepad), + SDL_PROP_GAMEPAD_CAP_RGB_LED_BOOLEAN, false)) { + m_gamepads.insert(event.gdevice.which, gamepad); + ApplyColor(gamepad); + } else { + SDL_CloseGamepad(gamepad); + } + } else if (event.type == SDL_EVENT_GAMEPAD_REMOVED) { + if (auto* gamepad = m_gamepads.take(event.gdevice.which); gamepad != nullptr) { + SDL_CloseGamepad(gamepad); + } + } + } +} + +void ControllerLightbar::ApplyColor(SDL_Gamepad* gamepad) const { + if (!SDL_SetGamepadLED(gamepad, static_cast(m_color.red()), + static_cast(m_color.green()), + static_cast(m_color.blue()))) { + qWarning() << "Cannot set controller LED:" << SDL_GetError(); + } +} diff --git a/src/launcher/src/mainDialog.cpp b/src/launcher/src/mainDialog.cpp index a9e80ea5d..ff8a2524c 100644 --- a/src/launcher/src/mainDialog.cpp +++ b/src/launcher/src/mainDialog.cpp @@ -3,6 +3,7 @@ #include "configuration.h" #include "configurationItem.h" #include "configurationListWidget.h" +#include "controllerLightbar.h" #include "gameContent.h" #include "patchesDialog.h" #include "updateChecker.h" @@ -88,6 +89,7 @@ private: Ui::MainDialog* m_ui = {nullptr}; MainDialog* m_main_dialog = nullptr; UpdateChecker* m_update_checker = nullptr; + ControllerLightbar m_lightbar; QString m_interpreter; QProcess m_process; @@ -103,6 +105,7 @@ MainDialog::MainDialog(QWidget* parent): QDialog(parent), m_p(new MainDialogPriv } MainDialogPrivate::~MainDialogPrivate() { + m_process.disconnect(this); delete m_ui; } @@ -121,6 +124,12 @@ void MainDialogPrivate::Setup(MainDialog* main_dialog) { connect(main_dialog, &MainDialog::Start, this, &MainDialogPrivate::FindInterpreter, Qt::QueuedConnection); connect(m_ui->widget, &ConfigurationListWidget::Select, this, &MainDialogPrivate::Update); + connect(m_ui->widget, &ConfigurationListWidget::PreviewControllerColor, this, + [this](const QString& color) { + if (m_process.state() == QProcess::NotRunning) { + m_lightbar.SetColor(color); + } + }); connect(m_ui->widget, &ConfigurationListWidget::Run, this, &MainDialogPrivate::Run); connect(m_ui->check_updates_link, &QLabel::linkActivated, this, [this](const QString&) { m_update_checker->Check(true); }); @@ -135,14 +144,14 @@ void MainDialogPrivate::Setup(MainDialog* main_dialog) { m_ui->widget->WriteSettings(); }); - connect(&m_process, - static_cast(&QProcess::finished), - [this](int /*exitCode*/, QProcess::ExitStatus /*exitStatus*/) { - if (m_running_item != nullptr) { - m_running_item->SetRunning(false); - } - Update(); - }); + connect(&m_process, &QProcess::stateChanged, this, [this](QProcess::ProcessState state) { + if (state == QProcess::NotRunning) { + if (m_running_item != nullptr) { + m_running_item->SetRunning(false); + } + Update(); + } + }); m_ui->label_settings_file->setText(tr("Settings file: ") + m_ui->widget->GetSettingsFile()); @@ -219,6 +228,9 @@ static QStringList CreateEmulatorArgs(const Configuration& info) { if (!info.audio_input_device.isEmpty()) { args << "--mic" << info.audio_input_device; } + if (!info.controller_color.isEmpty()) { + args << "--controller-color" << info.controller_color; + } args << "--present-mode" << EnumToText(info.present_mode); if (info.gpu_index >= 0) { args << "--gpu" << QString::number(info.gpu_index); @@ -493,6 +505,7 @@ void MainDialogPrivate::Run() { } m_running_item->SetRunning(true); + m_lightbar.Stop(); auto info = m_ui->widget->CreateConfiguration(*m_running_item); m_main_dialog->RunInterpreter(&m_process, *info); @@ -512,6 +525,12 @@ void MainDialogPrivate::Update() { } m_ui->widget->SetRunEnabled(run_enabled); + if (m_process.state() != QProcess::NotRunning) { + m_lightbar.Stop(); + return; + } + m_lightbar.SetColor(item != nullptr ? m_ui->widget->CreateConfiguration(*item)->controller_color + : m_ui->widget->GetGlobalControllerColor()); } #include "mainDialog.moc" diff --git a/src/libs/controller.cpp b/src/libs/controller.cpp index 9e11d3b6a..b8b4c5fed 100644 --- a/src/libs/controller.cpp +++ b/src/libs/controller.cpp @@ -297,6 +297,9 @@ void GameController::Connect(int id) { if (id != HOST_INPUT_CONTROLLER_ID) { if (auto* pad = SDL_GetGamepadFromID(static_cast(id)); pad != nullptr) { + if (const auto& color = Config::GetControllerColor()) { + (void)SDL_SetGamepadLED(pad, (*color)[0], (*color)[1], (*color)[2]); + } for (auto sensor: {SDL_SENSOR_ACCEL, SDL_SENSOR_GYRO}) { if (SDL_GamepadHasSensor(pad, sensor) && !SDL_SetGamepadSensorEnabled(pad, sensor, true)) { @@ -581,6 +584,11 @@ int GameController::GetActiveControllerId() { void GameController::SetLightBar(uint8_t r, uint8_t g, uint8_t b) { Common::LockGuard lock(m_mutex); + if (const auto& color = Config::GetControllerColor()) { + r = (*color)[0]; + g = (*color)[1]; + b = (*color)[2]; + } if (auto* pad = SDL_GetGamepadFromID(static_cast(m_active_id)); pad != nullptr) { (void)SDL_SetGamepadLED(pad, r, g, b); diff --git a/src/main.cpp b/src/main.cpp index 21bd7030a..a0154411b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -53,6 +53,7 @@ static void PrintUsage() { ::printf(" --user-id Local user ID. Default: %d.\n", Config::DEFAULT_USER_ID); ::printf(" --mic Capture from this microphone; omit for silence.\n"); + ::printf(" --controller-color <#RRGGBB> Override the controller lightbar color.\n"); ::printf( " --present-mode Fifo, Mailbox, or Immediate. Default: Mailbox.\n"); ::printf( @@ -145,6 +146,20 @@ static bool ParseUint32(const std::string& value, uint32_t& out) { return true; } +static bool ParseControllerColor(const std::string& value, Config::ControllerColor& out) { + if (value.size() != 7 || value[0] != '#') { + return false; + } + uint32_t rgb = 0; + auto [end, error] = std::from_chars(value.data() + 1, value.data() + value.size(), rgb, 16); + if (error != std::errc {} || end != value.data() + value.size()) { + return false; + } + out = {static_cast(rgb >> 16), static_cast(rgb >> 8), + static_cast(rgb)}; + return true; +} + static bool ParseInt32(const std::string& value, int32_t& out) { int32_t number = 0; auto [end, error] = std::from_chars(value.data(), value.data() + value.size(), number); @@ -302,6 +317,13 @@ static bool ParseArgs(int argc, char* argv[], RunOptions& options, bool& show_he } } else if (arg == "--mic") { options.config.audio_input_device = value; + } else if (arg == "--controller-color") { + Config::ControllerColor color {}; + if (!ParseControllerColor(value, color)) { + ::printf("invalid controller color (expected #RRGGBB): %s\n", value.c_str()); + return false; + } + options.config.controller_color = color; } else if (arg == "--present-mode") { if (!ParseEnum(value, options.config.present_mode)) { ::printf("invalid present mode: %s\n", value.c_str());