From 61c448eb000157a5105867d6fa1ad2e2440dce35 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Sat, 23 May 2026 21:47:15 +1200 Subject: [PATCH] Add SDL3-based controller input service Integrate SDL3 controller support and wire it into the InputRouter. - Add ControllerInputService (src/ControllerInputService.{h,cpp}) to discover, poll and translate SDL3 gamepad events into InputRouter actions, with axis repeat handling and debouncing. - Update CMakeLists to find or fetch SDL3, add the new source files to the target, link the SDL3 target, and copy runtime DLLs on Windows. - Add triggerAction(Action) to InputRouter and use it from existing keyboard handling to centralize action dispatch. - Instantiate ControllerInputService in main so controllers feed the InputRouter. - Update QML views (ShellWindow, HomeView, LibraryView, SettingsView) to use numeric action IDs and add small UI/status text and navigation tweaks for controller-driven flows. These changes enable gamepad/controller input for Bigscreen via SDL3 and adapt UI code to handle the mapped actions. --- Bigscreen/CMakeLists.txt | 39 +++ Bigscreen/qml/ShellWindow.qml | 10 +- Bigscreen/qml/views/HomeView.qml | 9 +- Bigscreen/qml/views/LibraryView.qml | 12 +- Bigscreen/qml/views/SettingsView.qml | 27 +- Bigscreen/src/ControllerInputService.cpp | 301 +++++++++++++++++++++++ Bigscreen/src/ControllerInputService.h | 52 ++++ Bigscreen/src/InputRouter.cpp | 11 +- Bigscreen/src/InputRouter.h | 1 + Bigscreen/src/main.cpp | 3 + 10 files changed, 444 insertions(+), 21 deletions(-) create mode 100644 Bigscreen/src/ControllerInputService.cpp create mode 100644 Bigscreen/src/ControllerInputService.h diff --git a/Bigscreen/CMakeLists.txt b/Bigscreen/CMakeLists.txt index 9c75757..24863f3 100644 --- a/Bigscreen/CMakeLists.txt +++ b/Bigscreen/CMakeLists.txt @@ -9,6 +9,30 @@ set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) find_package(Qt6 6.5 REQUIRED COMPONENTS Quick Gui) +find_package(SDL3 CONFIG QUIET) + +if(NOT SDL3_FOUND) + include(FetchContent) + set(SDL_SHARED ON CACHE BOOL "" FORCE) + set(SDL_STATIC OFF CACHE BOOL "" FORCE) + set(SDL_TEST_LIBRARY OFF CACHE BOOL "" FORCE) + set(SDL_TESTS OFF CACHE BOOL "" FORCE) + set(SDL_EXAMPLES OFF CACHE BOOL "" FORCE) + + FetchContent_Declare(SDL3 + URL https://github.com/libsdl-org/SDL/releases/download/release-3.2.30/SDL3-3.2.30.tar.gz + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + ) + FetchContent_MakeAvailable(SDL3) +endif() + +if(TARGET SDL3::SDL3) + set(BIGSCREEN_SDL_TARGET SDL3::SDL3) +elseif(TARGET SDL3::SDL3-shared) + set(BIGSCREEN_SDL_TARGET SDL3::SDL3-shared) +else() + message(FATAL_ERROR "SDL3 target was not found after package lookup/fetch") +endif() qt_standard_project_setup(REQUIRES 6.5) qt_policy(SET QTP0004 NEW) @@ -31,6 +55,8 @@ set_source_files_properties(qml/Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE) qt_add_executable(nebula-bigscreen src/main.cpp + src/ControllerInputService.cpp + src/ControllerInputService.h src/InputRouter.cpp src/InputRouter.h ) @@ -45,8 +71,21 @@ target_link_libraries(nebula-bigscreen PRIVATE Qt6::Quick Qt6::Gui + ${BIGSCREEN_SDL_TARGET} ) +if(WIN32) + add_custom_command( + TARGET nebula-bigscreen + POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND_EXPAND_LISTS + COMMENT "Copying runtime DLLs next to nebula-bigscreen" + ) +endif() + if(WIN32) set_target_properties(nebula-bigscreen PROPERTIES WIN32_EXECUTABLE TRUE) diff --git a/Bigscreen/qml/ShellWindow.qml b/Bigscreen/qml/ShellWindow.qml index dc5ef9d..892d8c7 100644 --- a/Bigscreen/qml/ShellWindow.qml +++ b/Bigscreen/qml/ShellWindow.qml @@ -70,7 +70,7 @@ ApplicationWindow { window.handlePowerInput(action) return } - if (action === InputRouter.Menu) { + if (action === 7) { window.powerOverlayVisible = true return } @@ -120,16 +120,16 @@ ApplicationWindow { function handlePowerInput(action) { switch (action) { - case InputRouter.Up: + case 1: powerOverlay.moveFocus(-1) break - case InputRouter.Down: + case 2: powerOverlay.moveFocus(1) break - case InputRouter.Accept: + case 5: powerOverlay.activateFocused() break - case InputRouter.Back: + case 6: window.powerOverlayVisible = false break default: diff --git a/Bigscreen/qml/views/HomeView.qml b/Bigscreen/qml/views/HomeView.qml index 71b8234..ecdd307 100644 --- a/Bigscreen/qml/views/HomeView.qml +++ b/Bigscreen/qml/views/HomeView.qml @@ -39,15 +39,18 @@ ColumnLayout { function handleInput(action) { switch (action) { - case InputRouter.Left: + case 3: rail.moveFocus(-1) break - case InputRouter.Right: + case 4: rail.moveFocus(1) break - case InputRouter.Accept: + case 5: rail.activateFocused() break + case 6: + root.navigate("power") + break default: break } diff --git a/Bigscreen/qml/views/LibraryView.qml b/Bigscreen/qml/views/LibraryView.qml index 876de64..8229ff3 100644 --- a/Bigscreen/qml/views/LibraryView.qml +++ b/Bigscreen/qml/views/LibraryView.qml @@ -14,6 +14,7 @@ ColumnLayout { ] property int focusIndex: 0 + property string statusText: "Mock entries for v0 — scanners and launchers come later." spacing: 20 @@ -25,7 +26,7 @@ ColumnLayout { } Text { - text: "Mock entries for v0 — scanners and launchers come later." + text: root.statusText font: Theme.metaFont color: Theme.textMuted Layout.leftMargin: 40 @@ -79,18 +80,19 @@ ColumnLayout { function handleInput(action) { switch (action) { - case InputRouter.Up: + case 1: focusIndex = Math.max(0, focusIndex - 1) list.currentIndex = focusIndex break - case InputRouter.Down: + case 2: focusIndex = Math.min(entries.length - 1, focusIndex + 1) list.currentIndex = focusIndex break - case InputRouter.Back: + case 6: root.goBack() break - case InputRouter.Accept: + case 5: + statusText = entries[focusIndex].title + " selected — launcher integration comes later." break default: break diff --git a/Bigscreen/qml/views/SettingsView.qml b/Bigscreen/qml/views/SettingsView.qml index f165e6e..036cc71 100644 --- a/Bigscreen/qml/views/SettingsView.qml +++ b/Bigscreen/qml/views/SettingsView.qml @@ -17,6 +17,8 @@ ColumnLayout { property int categoryIndex: 0 property int itemIndex: 0 + readonly property int settingCount: 3 + property string statusText: "Choose a setting with Accept. Detailed controls come later." spacing: 20 @@ -73,7 +75,7 @@ ColumnLayout { } Repeater { - model: 3 + model: root.settingCount Rectangle { Layout.fillWidth: true @@ -93,26 +95,37 @@ ColumnLayout { } } } + + Text { + text: root.statusText + font: Theme.metaFont + color: Theme.textMuted + } } } function handleInput(action) { switch (action) { - case InputRouter.Left: + case 3: categoryIndex = Math.max(0, categoryIndex - 1) + itemIndex = 0 break - case InputRouter.Right: + case 4: categoryIndex = Math.min(categories.length - 1, categoryIndex + 1) + itemIndex = 0 break - case InputRouter.Up: + case 1: itemIndex = Math.max(0, itemIndex - 1) break - case InputRouter.Down: - itemIndex = Math.min(2, itemIndex + 1) + case 2: + itemIndex = Math.min(settingCount - 1, itemIndex + 1) break - case InputRouter.Back: + case 6: root.goBack() break + case 5: + statusText = categories[categoryIndex] + " setting " + (itemIndex + 1) + " selected." + break default: break } diff --git a/Bigscreen/src/ControllerInputService.cpp b/Bigscreen/src/ControllerInputService.cpp new file mode 100644 index 0000000..de075fd --- /dev/null +++ b/Bigscreen/src/ControllerInputService.cpp @@ -0,0 +1,301 @@ +#include "ControllerInputService.h" + +#include + +#include + +namespace { +constexpr int PollIntervalMs = 8; +constexpr int AxisDeadzone = 4000; +constexpr int AxisReleaseDeadzone = 2000; +constexpr qint64 InitialRepeatDelayMs = 260; +constexpr qint64 RepeatIntervalMs = 95; +constexpr qint64 DiscoveryIntervalMs = 1000; +constexpr int AxisControlOffset = 100; +} + +ControllerInputService::ControllerInputService(InputRouter *router, QObject *parent) + : QObject(parent) + , m_router(router) +{ + if (!m_router) { + qWarning() << "ControllerInputService disabled: missing InputRouter"; + return; + } + + SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + + if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) { + qWarning() << "ControllerInputService disabled:" << SDL_GetError(); + return; + } + + m_sdlReady = true; + m_clock.start(); + m_nextDiscoveryMs = DiscoveryIntervalMs; + + discoverControllers(); + + connect(&m_pollTimer, &QTimer::timeout, this, &ControllerInputService::pollEvents); + m_pollTimer.start(PollIntervalMs); +} + +ControllerInputService::~ControllerInputService() +{ + m_pollTimer.stop(); + + for (SDL_Gamepad *gamepad : std::as_const(m_gamepads)) { + SDL_CloseGamepad(gamepad); + } + m_gamepads.clear(); + + if (m_sdlReady) { + SDL_QuitSubSystem(SDL_INIT_GAMEPAD); + } +} + +void ControllerInputService::pollEvents() +{ + if (!m_sdlReady) { + return; + } + + SDL_Event event; + while (SDL_PollEvent(&event)) { + switch (event.type) { + case SDL_EVENT_GAMEPAD_ADDED: + openController(event.gdevice.which); + break; + case SDL_EVENT_GAMEPAD_REMOVED: + closeController(event.gdevice.which); + break; + case SDL_EVENT_GAMEPAD_BUTTON_DOWN: + handleButtonDown(event.gbutton); + break; + case SDL_EVENT_GAMEPAD_AXIS_MOTION: + handleAxisMotion(event.gaxis); + break; + default: + break; + } + } + + const qint64 now = m_clock.elapsed(); + if (now >= m_nextDiscoveryMs) { + discoverControllers(); + m_nextDiscoveryMs = now + DiscoveryIntervalMs; + } + + pollControllerState(); + updateAxisRepeats(); +} + +void ControllerInputService::discoverControllers() +{ + int gamepadCount = 0; + SDL_JoystickID *gamepads = SDL_GetGamepads(&gamepadCount); + if (!gamepads) { + return; + } + + for (int i = 0; i < gamepadCount; ++i) { + openController(gamepads[i]); + } + + SDL_free(gamepads); +} + +void ControllerInputService::pollControllerState() +{ + QHash::iterator it = m_gamepads.begin(); + while (it != m_gamepads.end()) { + const SDL_JoystickID instanceId = it.key(); + SDL_Gamepad *gamepad = it.value(); + + if (!gamepad) { + ++it; + continue; + } + + for (int button = 0; button < SDL_GAMEPAD_BUTTON_COUNT; ++button) { + const qint64 key = stateKey(instanceId, button); + const bool pressed = SDL_GetGamepadButton(gamepad, static_cast(button)); + const bool wasPressed = m_pressedButtons.contains(key); + + if (pressed && !wasPressed) { + m_pressedButtons.insert(key); + dispatchAction(actionForButton(static_cast(button))); + } else if (!pressed && wasPressed) { + m_pressedButtons.remove(key); + } + } + + for (int axis = 0; axis < SDL_GAMEPAD_AXIS_COUNT; ++axis) { + const Sint16 value = SDL_GetGamepadAxis(gamepad, static_cast(axis)); + updateAxisHold(stateKey(instanceId, AxisControlOffset + axis), + actionForAxis(static_cast(axis), value)); + } + + ++it; + } +} + +void ControllerInputService::updateAxisRepeats() +{ + const qint64 now = m_clock.elapsed(); + for (auto it = m_axisHolds.begin(); it != m_axisHolds.end(); ++it) { + AxisHold &hold = it.value(); + if (!hold.active || hold.action == InputRouter::Action::None || now < hold.nextRepeatMs) { + continue; + } + + dispatchAction(hold.action); + hold.nextRepeatMs = now + RepeatIntervalMs; + } +} + +void ControllerInputService::openController(SDL_JoystickID instanceId) +{ + if (m_gamepads.contains(instanceId) || !SDL_IsGamepad(instanceId)) { + return; + } + + SDL_Gamepad *gamepad = SDL_OpenGamepad(instanceId); + if (!gamepad) { + qWarning() << "Failed to open gamepad" << instanceId << SDL_GetError(); + return; + } + + m_gamepads.insert(instanceId, gamepad); + qInfo() << "Opened gamepad" << instanceId << SDL_GetGamepadName(gamepad); +} + +void ControllerInputService::closeController(SDL_JoystickID instanceId) +{ + SDL_Gamepad *gamepad = m_gamepads.take(instanceId); + if (!gamepad) { + return; + } + + SDL_CloseGamepad(gamepad); + m_axisHolds.clear(); + m_pressedButtons.clear(); + qInfo() << "Closed gamepad" << instanceId; +} + +void ControllerInputService::handleButtonDown(const SDL_GamepadButtonEvent &event) +{ + const qint64 key = stateKey(event.which, event.button); + if (m_pressedButtons.contains(key)) { + return; + } + + m_pressedButtons.insert(key); + dispatchAction(actionForButton(event.button)); +} + +void ControllerInputService::handleAxisMotion(const SDL_GamepadAxisEvent &event) +{ + updateAxisHold(stateKey(event.which, AxisControlOffset + event.axis), + actionForAxis(event.axis, event.value)); +} + +void ControllerInputService::updateAxisHold(qint64 axisKey, InputRouter::Action action) +{ + if (action == InputRouter::Action::None) { + releaseAxisHold(axisKey); + return; + } + + AxisHold &hold = m_axisHolds[axisKey]; + if (hold.active && hold.action == action) { + return; + } + + hold.active = true; + hold.action = action; + hold.nextRepeatMs = m_clock.elapsed() + InitialRepeatDelayMs; + dispatchAction(action); +} + +void ControllerInputService::releaseAxisHold(qint64 axisKey) +{ + AxisHold &hold = m_axisHolds[axisKey]; + hold.active = false; + hold.action = InputRouter::Action::None; + hold.nextRepeatMs = 0; +} + +void ControllerInputService::dispatchAction(InputRouter::Action action) +{ + if (m_router && action != InputRouter::Action::None) { + m_router->triggerAction(action); + } +} + +InputRouter::Action ControllerInputService::actionForButton(Uint8 button) +{ + const SDL_GamepadButton buttonEnum = static_cast(button); + + switch (buttonEnum) { + case SDL_GAMEPAD_BUTTON_SOUTH: + return InputRouter::Action::Back; + case SDL_GAMEPAD_BUTTON_EAST: + return InputRouter::Action::Accept; + case SDL_GAMEPAD_BUTTON_WEST: + return InputRouter::Action::None; + case SDL_GAMEPAD_BUTTON_NORTH: + return InputRouter::Action::None; + case SDL_GAMEPAD_BUTTON_DPAD_UP: + return InputRouter::Action::Up; + case SDL_GAMEPAD_BUTTON_DPAD_DOWN: + return InputRouter::Action::Down; + case SDL_GAMEPAD_BUTTON_DPAD_LEFT: + return InputRouter::Action::Left; + case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: + return InputRouter::Action::Right; + case SDL_GAMEPAD_BUTTON_START: + case SDL_GAMEPAD_BUTTON_BACK: + case SDL_GAMEPAD_BUTTON_GUIDE: + return InputRouter::Action::Menu; + default: + return InputRouter::Action::None; + } +} + +InputRouter::Action ControllerInputService::actionForAxis(Uint8 axis, Sint16 value) +{ + if (value > -AxisReleaseDeadzone && value < AxisReleaseDeadzone) { + return InputRouter::Action::None; + } + + const SDL_GamepadAxis axisEnum = static_cast(axis); + + switch (axisEnum) { + case SDL_GAMEPAD_AXIS_LEFTX: + if (value <= -AxisDeadzone) { + return InputRouter::Action::Left; + } + if (value >= AxisDeadzone) { + return InputRouter::Action::Right; + } + break; + case SDL_GAMEPAD_AXIS_LEFTY: + if (value <= -AxisDeadzone) { + return InputRouter::Action::Up; + } + if (value >= AxisDeadzone) { + return InputRouter::Action::Down; + } + break; + default: + break; + } + + return InputRouter::Action::None; +} + +qint64 ControllerInputService::stateKey(SDL_JoystickID instanceId, int control) +{ + return (static_cast(instanceId) << 16) | control; +} diff --git a/Bigscreen/src/ControllerInputService.h b/Bigscreen/src/ControllerInputService.h new file mode 100644 index 0000000..2bf21ae --- /dev/null +++ b/Bigscreen/src/ControllerInputService.h @@ -0,0 +1,52 @@ +#pragma once + +#include "InputRouter.h" + +#include +#include +#include +#include +#include + +#include + +class ControllerInputService : public QObject +{ + Q_OBJECT + +public: + explicit ControllerInputService(InputRouter *router, QObject *parent = nullptr); + ~ControllerInputService() override; + +private: + struct AxisHold { + InputRouter::Action action = InputRouter::Action::None; + qint64 nextRepeatMs = 0; + bool active = false; + }; + + void pollEvents(); + void discoverControllers(); + void pollControllerState(); + void updateAxisRepeats(); + void openController(SDL_JoystickID instanceId); + void closeController(SDL_JoystickID instanceId); + void handleButtonDown(const SDL_GamepadButtonEvent &event); + void handleAxisMotion(const SDL_GamepadAxisEvent &event); + void updateAxisHold(qint64 axisKey, InputRouter::Action action); + void releaseAxisHold(qint64 axisKey); + void dispatchAction(InputRouter::Action action); + + static InputRouter::Action actionForButton(Uint8 button); + static InputRouter::Action actionForAxis(Uint8 axis, Sint16 value); + static qint64 stateKey(SDL_JoystickID instanceId, int control); + + InputRouter *m_router = nullptr; + QTimer m_pollTimer; + QElapsedTimer m_clock; + QHash m_gamepads; + QSet m_pressedButtons; + QHash m_axisHolds; + qint64 m_nextDiscoveryMs = 0; + bool m_sdlReady = false; +}; diff --git a/Bigscreen/src/InputRouter.cpp b/Bigscreen/src/InputRouter.cpp index 5446120..9282881 100644 --- a/Bigscreen/src/InputRouter.cpp +++ b/Bigscreen/src/InputRouter.cpp @@ -18,11 +18,20 @@ bool InputRouter::handleKeyPress(QKeyEvent *event) return false; } - emit actionTriggered(action); + triggerAction(action); event->accept(); return true; } +void InputRouter::triggerAction(Action action) +{ + if (action == Action::None) { + return; + } + + emit actionTriggered(action); +} + InputRouter::Action InputRouter::mapKey(int key) { switch (key) { diff --git a/Bigscreen/src/InputRouter.h b/Bigscreen/src/InputRouter.h index 64e203a..45d6b37 100644 --- a/Bigscreen/src/InputRouter.h +++ b/Bigscreen/src/InputRouter.h @@ -24,6 +24,7 @@ public: explicit InputRouter(QObject *parent = nullptr); bool handleKeyPress(QKeyEvent *event); + void triggerAction(Action action); signals: void actionTriggered(InputRouter::Action action); diff --git a/Bigscreen/src/main.cpp b/Bigscreen/src/main.cpp index 9459325..16ed699 100644 --- a/Bigscreen/src/main.cpp +++ b/Bigscreen/src/main.cpp @@ -1,3 +1,4 @@ +#include "ControllerInputService.h" #include "InputRouter.h" #include @@ -81,6 +82,8 @@ int main(int argc, char *argv[]) InputRouter inputRouter; InputFilter inputFilter(&inputRouter); + ControllerInputService controllerInputService(&inputRouter); + Q_UNUSED(controllerInputService); app.installEventFilter(&inputFilter); QQmlApplicationEngine engine;