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.
This commit is contained in:
2026-05-23 21:47:15 +12:00
parent f8632e40e7
commit 61c448eb00
10 changed files with 444 additions and 21 deletions
+39
View File
@@ -9,6 +9,30 @@ set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON) set(CMAKE_AUTORCC ON)
find_package(Qt6 6.5 REQUIRED COMPONENTS Quick Gui) 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_standard_project_setup(REQUIRES 6.5)
qt_policy(SET QTP0004 NEW) 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 qt_add_executable(nebula-bigscreen
src/main.cpp src/main.cpp
src/ControllerInputService.cpp
src/ControllerInputService.h
src/InputRouter.cpp src/InputRouter.cpp
src/InputRouter.h src/InputRouter.h
) )
@@ -45,8 +71,21 @@ target_link_libraries(nebula-bigscreen
PRIVATE PRIVATE
Qt6::Quick Qt6::Quick
Qt6::Gui Qt6::Gui
${BIGSCREEN_SDL_TARGET}
) )
if(WIN32)
add_custom_command(
TARGET nebula-bigscreen
POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
$<TARGET_RUNTIME_DLLS:nebula-bigscreen>
$<TARGET_FILE_DIR:nebula-bigscreen>
COMMAND_EXPAND_LISTS
COMMENT "Copying runtime DLLs next to nebula-bigscreen"
)
endif()
if(WIN32) if(WIN32)
set_target_properties(nebula-bigscreen PROPERTIES WIN32_EXECUTABLE TRUE) set_target_properties(nebula-bigscreen PROPERTIES WIN32_EXECUTABLE TRUE)
+5 -5
View File
@@ -70,7 +70,7 @@ ApplicationWindow {
window.handlePowerInput(action) window.handlePowerInput(action)
return return
} }
if (action === InputRouter.Menu) { if (action === 7) {
window.powerOverlayVisible = true window.powerOverlayVisible = true
return return
} }
@@ -120,16 +120,16 @@ ApplicationWindow {
function handlePowerInput(action) { function handlePowerInput(action) {
switch (action) { switch (action) {
case InputRouter.Up: case 1:
powerOverlay.moveFocus(-1) powerOverlay.moveFocus(-1)
break break
case InputRouter.Down: case 2:
powerOverlay.moveFocus(1) powerOverlay.moveFocus(1)
break break
case InputRouter.Accept: case 5:
powerOverlay.activateFocused() powerOverlay.activateFocused()
break break
case InputRouter.Back: case 6:
window.powerOverlayVisible = false window.powerOverlayVisible = false
break break
default: default:
+6 -3
View File
@@ -39,15 +39,18 @@ ColumnLayout {
function handleInput(action) { function handleInput(action) {
switch (action) { switch (action) {
case InputRouter.Left: case 3:
rail.moveFocus(-1) rail.moveFocus(-1)
break break
case InputRouter.Right: case 4:
rail.moveFocus(1) rail.moveFocus(1)
break break
case InputRouter.Accept: case 5:
rail.activateFocused() rail.activateFocused()
break break
case 6:
root.navigate("power")
break
default: default:
break break
} }
+7 -5
View File
@@ -14,6 +14,7 @@ ColumnLayout {
] ]
property int focusIndex: 0 property int focusIndex: 0
property string statusText: "Mock entries for v0 — scanners and launchers come later."
spacing: 20 spacing: 20
@@ -25,7 +26,7 @@ ColumnLayout {
} }
Text { Text {
text: "Mock entries for v0 — scanners and launchers come later." text: root.statusText
font: Theme.metaFont font: Theme.metaFont
color: Theme.textMuted color: Theme.textMuted
Layout.leftMargin: 40 Layout.leftMargin: 40
@@ -79,18 +80,19 @@ ColumnLayout {
function handleInput(action) { function handleInput(action) {
switch (action) { switch (action) {
case InputRouter.Up: case 1:
focusIndex = Math.max(0, focusIndex - 1) focusIndex = Math.max(0, focusIndex - 1)
list.currentIndex = focusIndex list.currentIndex = focusIndex
break break
case InputRouter.Down: case 2:
focusIndex = Math.min(entries.length - 1, focusIndex + 1) focusIndex = Math.min(entries.length - 1, focusIndex + 1)
list.currentIndex = focusIndex list.currentIndex = focusIndex
break break
case InputRouter.Back: case 6:
root.goBack() root.goBack()
break break
case InputRouter.Accept: case 5:
statusText = entries[focusIndex].title + " selected — launcher integration comes later."
break break
default: default:
break break
+20 -7
View File
@@ -17,6 +17,8 @@ ColumnLayout {
property int categoryIndex: 0 property int categoryIndex: 0
property int itemIndex: 0 property int itemIndex: 0
readonly property int settingCount: 3
property string statusText: "Choose a setting with Accept. Detailed controls come later."
spacing: 20 spacing: 20
@@ -73,7 +75,7 @@ ColumnLayout {
} }
Repeater { Repeater {
model: 3 model: root.settingCount
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
@@ -93,26 +95,37 @@ ColumnLayout {
} }
} }
} }
Text {
text: root.statusText
font: Theme.metaFont
color: Theme.textMuted
}
} }
} }
function handleInput(action) { function handleInput(action) {
switch (action) { switch (action) {
case InputRouter.Left: case 3:
categoryIndex = Math.max(0, categoryIndex - 1) categoryIndex = Math.max(0, categoryIndex - 1)
itemIndex = 0
break break
case InputRouter.Right: case 4:
categoryIndex = Math.min(categories.length - 1, categoryIndex + 1) categoryIndex = Math.min(categories.length - 1, categoryIndex + 1)
itemIndex = 0
break break
case InputRouter.Up: case 1:
itemIndex = Math.max(0, itemIndex - 1) itemIndex = Math.max(0, itemIndex - 1)
break break
case InputRouter.Down: case 2:
itemIndex = Math.min(2, itemIndex + 1) itemIndex = Math.min(settingCount - 1, itemIndex + 1)
break break
case InputRouter.Back: case 6:
root.goBack() root.goBack()
break break
case 5:
statusText = categories[categoryIndex] + " setting " + (itemIndex + 1) + " selected."
break
default: default:
break break
} }
+301
View File
@@ -0,0 +1,301 @@
#include "ControllerInputService.h"
#include <QDebug>
#include <utility>
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<SDL_JoystickID, SDL_Gamepad *>::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<SDL_GamepadButton>(button));
const bool wasPressed = m_pressedButtons.contains(key);
if (pressed && !wasPressed) {
m_pressedButtons.insert(key);
dispatchAction(actionForButton(static_cast<Uint8>(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<SDL_GamepadAxis>(axis));
updateAxisHold(stateKey(instanceId, AxisControlOffset + axis),
actionForAxis(static_cast<Uint8>(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<SDL_GamepadButton>(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<SDL_GamepadAxis>(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<qint64>(instanceId) << 16) | control;
}
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include "InputRouter.h"
#include <QElapsedTimer>
#include <QHash>
#include <QObject>
#include <QSet>
#include <QTimer>
#include <SDL3/SDL.h>
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<SDL_JoystickID, SDL_Gamepad *> m_gamepads;
QSet<qint64> m_pressedButtons;
QHash<qint64, AxisHold> m_axisHolds;
qint64 m_nextDiscoveryMs = 0;
bool m_sdlReady = false;
};
+10 -1
View File
@@ -18,11 +18,20 @@ bool InputRouter::handleKeyPress(QKeyEvent *event)
return false; return false;
} }
emit actionTriggered(action); triggerAction(action);
event->accept(); event->accept();
return true; return true;
} }
void InputRouter::triggerAction(Action action)
{
if (action == Action::None) {
return;
}
emit actionTriggered(action);
}
InputRouter::Action InputRouter::mapKey(int key) InputRouter::Action InputRouter::mapKey(int key)
{ {
switch (key) { switch (key) {
+1
View File
@@ -24,6 +24,7 @@ public:
explicit InputRouter(QObject *parent = nullptr); explicit InputRouter(QObject *parent = nullptr);
bool handleKeyPress(QKeyEvent *event); bool handleKeyPress(QKeyEvent *event);
void triggerAction(Action action);
signals: signals:
void actionTriggered(InputRouter::Action action); void actionTriggered(InputRouter::Action action);
+3
View File
@@ -1,3 +1,4 @@
#include "ControllerInputService.h"
#include "InputRouter.h" #include "InputRouter.h"
#include <QCoreApplication> #include <QCoreApplication>
@@ -81,6 +82,8 @@ int main(int argc, char *argv[])
InputRouter inputRouter; InputRouter inputRouter;
InputFilter inputFilter(&inputRouter); InputFilter inputFilter(&inputRouter);
ControllerInputService controllerInputService(&inputRouter);
Q_UNUSED(controllerInputService);
app.installEventFilter(&inputFilter); app.installEventFilter(&inputFilter);
QQmlApplicationEngine engine; QQmlApplicationEngine engine;