feat: switch gamepad reader to Linux evdev BTN_* codes

The Joystick API (/dev/input/js*) numbers buttons depending on driver
and connection type. Xbox One controllers via Bluetooth with a Share
button shifted X from button number 2 to 3, silently breaking the
mapping.

The evdev interface provides standardized BTN_* codes (BTN_SOUTH=A,
BTN_NORTH=X, etc.) that remain consistent across Xbox, PlayStation,
and Nintendo controllers over both USB and Bluetooth.

js* devices remain the discovery path, while /sys/class/input/jsN is
used to resolve the corresponding event* device. If the /sys lookup
fails, JoystickFallbackReader falls back to the old Joystick API.
This commit is contained in:
Andre
2026-05-11 17:29:54 +02:00
parent 1251cc278a
commit 82ff6dbbf2
9 changed files with 306 additions and 96 deletions
+12 -9
View File
@@ -10,6 +10,7 @@ Twitch-Client für das Steam Deck. Electron + React, gamepad-navigierbar, Big-Sc
|---|---| |---|---|
| Framework | Electron 33, electron-vite, React 18, TypeScript 5.5 | | Framework | Electron 33, electron-vite, React 18, TypeScript 5.5 |
| Playback | hls.js 1.6 (HTML5 `<video>` im Renderer), Streamlink 6.11.0 im Flatpak-Build / 8.2.1 im Windows-Dev (nur für `--stream-url`) | | Playback | hls.js 1.6 (HTML5 `<video>` im Renderer), Streamlink 6.11.0 im Flatpak-Build / 8.2.1 im Windows-Dev (nur für `--stream-url`) |
| Gamepad (Linux) | evdev `/dev/input/event*` mit BTN_*-Codes (Xbox, PlayStation, Nintendo; USB + Bluetooth) |
| DB | better-sqlite3 (VOD-Verlauf + Resume-Positionen) | | DB | better-sqlite3 (VOD-Verlauf + Resume-Positionen) |
| Packaging | Flatpak (freedesktop SDK 24.08, Electron2 BaseApp) | | Packaging | Flatpak (freedesktop SDK 24.08, Electron2 BaseApp) |
| Build-Host | Windows, Flatpak-Build läuft in WSL2 | | Build-Host | Windows, Flatpak-Build läuft in WSL2 |
@@ -26,7 +27,7 @@ Electron Main Process
├── PlaybackService — Live-/VOD-Orchestrierung ├── PlaybackService — Live-/VOD-Orchestrierung
│ └── streamlink.ts — getStreamUrl() + getAvailableQualities() via streamlink │ └── streamlink.ts — getStreamUrl() + getAvailableQualities() via streamlink
├── historyRepo — SQLite VOD-Verlauf (Resume, Completed) ├── historyRepo — SQLite VOD-Verlauf (Resume, Completed)
└── gamepadReader — Linux /dev/input/js* Joystick-Leser └── gamepadReader — Linux evdev /dev/input/event* (via js*-Discovery, BTN_*-Codes)
Preload (contextBridge) Preload (contextBridge)
└── window.t4sd — Typisierte IPC-Bridge (auth, twitch, history, playback, gamepad) └── window.t4sd — Typisierte IPC-Bridge (auth, twitch, history, playback, gamepad)
@@ -55,7 +56,7 @@ Renderer (React)
- **Qualitätswechsel:** `videoRef.current.stop()` → `startLive/startVod(quality)` → neuer `playback:hls-url` Event — streamlink-Neustart nötig, da Single-Bitrate-HLS (kein hls.js Level-Switch möglich) - **Qualitätswechsel:** `videoRef.current.stop()` → `startLive/startVod(quality)` → neuer `playback:hls-url` Event — streamlink-Neustart nötig, da Single-Bitrate-HLS (kein hls.js Level-Switch möglich)
- **Qualitätsliste:** `IPC playback:get-qualities` → `streamlink --json <url>` → `Object.keys(streams)` sortiert nach fixem Order-Array - **Qualitätsliste:** `IPC playback:get-qualities` → `streamlink --json <url>` → `Object.keys(streams)` sortiert nach fixem Order-Array
- **Position-Tracking:** `VideoPlayer` meldet alle 5s via IPC `playback:report-position` → Main → SQLite - **Position-Tracking:** `VideoPlayer` meldet alle 5s via IPC `playback:report-position` → Main → SQLite
- **Gamepad:** `/dev/input/js*` (Linux/Gaming-Mode) **oder** `navigator.getGamepads()` (Windows/Dev) → synthetische `KeyboardEvent`s → alle Screen-Handler reagieren auf Keys - **Gamepad:** evdev `/dev/input/event*` via js*-Discovery (Linux/Gaming-Mode) **oder** `navigator.getGamepads()` (Windows/Dev) → synthetische `KeyboardEvent`s → alle Screen-Handler reagieren auf Keys
--- ---
@@ -98,7 +99,7 @@ src/main/
deviceCodeFlow.ts — OAuth Device Code + Polling (https://id.twitch.tv/oauth2/) deviceCodeFlow.ts — OAuth Device Code + Polling (https://id.twitch.tv/oauth2/)
tokenStore.ts — safeStorage-Verschlüsselung, Fallback auf plaintext tokenStore.ts — safeStorage-Verschlüsselung, Fallback auf plaintext
input/ input/
gamepadReader.ts — /dev/input/js* Leser, Hotplug (3s-Scan), Deduplizierung (40ms) gamepadReader.ts — evdev /dev/input/event* Leser (via js*-Discovery), Hotplug (3s-Scan), Deduplizierung (40ms); BTN_*-Codes für controller-unabhängiges Mapping
ipc/ ipc/
handlers.ts — Alle ipcMain.handle()-Registrierungen + Event-Forwarding handlers.ts — Alle ipcMain.handle()-Registrierungen + Event-Forwarding
playback/ playback/
@@ -217,10 +218,11 @@ Video läuft als HTML5 `<video>` Element innerhalb des Electron-Fensters — **k
Wenn der User während der Wiedergabe das Kapitel-Panel öffnet (Y-Taste), läuft das Video **weiter** (nicht pausiert). `PlaybackOverlay` wird per conditional render (`!chapterPanelVod`) ausgeblendet, sodass das Kapitel-Panel sauber über dem Video erscheint. Nach Kapitelwahl: `seekTo(chapter.positionSeconds)` — kein `play()` nötig, da Video bereits läuft. Nach Schließen ohne Auswahl: Panel weg, Video läuft unverändert weiter. Bei leerer Kapitelliste: X-Taste seeked zu `0` (Videoanfang). Wenn der User während der Wiedergabe das Kapitel-Panel öffnet (Y-Taste), läuft das Video **weiter** (nicht pausiert). `PlaybackOverlay` wird per conditional render (`!chapterPanelVod`) ausgeblendet, sodass das Kapitel-Panel sauber über dem Video erscheint. Nach Kapitelwahl: `seekTo(chapter.positionSeconds)` — kein `play()` nötig, da Video bereits läuft. Nach Schließen ohne Auswahl: Panel weg, Video läuft unverändert weiter. Bei leerer Kapitelliste: X-Taste seeked zu `0` (Videoanfang).
### Gamepad Dual-Path ### Gamepad Dual-Path
- **Linux/Gaming-Mode:** `src/main/input/gamepadReader.ts` liest `/dev/input/js*` direkt (Chromium Gamepad API funktioniert nicht im Flatpak-Sandbox ohne udev) - **Linux/Gaming-Mode:** `src/main/input/gamepadReader.ts` nutzt Linux evdev (`/dev/input/event*`). Erkennung läuft über `/dev/input/js*`-Scan; für jedes js*-Device wird via `/sys/class/input/jsN` das zugehörige `event*`-Device ermittelt und geöffnet. Dadurch werden standardisierte `BTN_*`-Codes gelesen (BTN_NORTH=X, BTN_SOUTH=A usw.) — funktioniert treiber- und controller-unabhängig für Xbox, PlayStation, Nintendo und beliebige USB/Bluetooth-Gamepads.
- **Windows/Dev:** `src/renderer/src/input/gamepad.ts` nutzt `navigator.getGamepads()` via rAF-Loop - **Windows/Dev:** `src/renderer/src/input/gamepad.ts` nutzt `navigator.getGamepads()` via rAF-Loop
- Beide Pfade erzeugen synthetische `KeyboardEvent('keydown')`. Alle UI-Komponenten reagieren nur auf Key-Events — kein Gamepad-Code in Screens. - Beide Pfade erzeugen synthetische `KeyboardEvent('keydown')`. Alle UI-Komponenten reagieren nur auf Key-Events — kein Gamepad-Code in Screens.
- Button-Mapping: A=Enter, B=Escape, X=x, Y=y, LB=l1, RB=r1, LT=l2, RT=r2, DPad=Arrows - Button-Mapping: A=Enter, B=Escape, X=x, Y=y, LB=l1, RB=r1, LT=l2, RT=r2, DPad=Arrows
- Fallback: Falls `/sys`-Lookup fehlschlägt, öffnet `JoystickFallbackReader` das js*-Device direkt (altes Joystick-API mit festem Button-Nummern-Mapping)
### A-Button-Verhalten je Screen ### A-Button-Verhalten je Screen
| Screen | A-Button | X-Button | | Screen | A-Button | X-Button |
@@ -261,8 +263,8 @@ Wenn der User während der Wiedergabe das Kapitel-Panel öffnet (Y-Taste), läuf
| Twitch Live-Stream URL-Expiry | Unkritisch | `streamlink --stream-url` Token in URL kann ablaufen. hls.js handelt Playlist-Refresh automatisch; bei Verbindungsabbruch muss neu gestartet werden. | | Twitch Live-Stream URL-Expiry | Unkritisch | `streamlink --stream-url` Token in URL kann ablaufen. hls.js handelt Playlist-Refresh automatisch; bei Verbindungsabbruch muss neu gestartet werden. |
| Ad-Bypass | Nicht implementiert | `--twitch-disable-ads` von Streamlink deprecated. Post-MVP. | | Ad-Bypass | Nicht implementiert | `--twitch-disable-ads` von Streamlink deprecated. Post-MVP. |
| Flaggen-Emojis auf Windows | Nur Rechtecke | Unicode Regional Indicators brauchen Noto Color Emoji (Linux). Im Dev-Mode ignorieren. | | Flaggen-Emojis auf Windows | Nur Rechtecke | Unicode Regional Indicators brauchen Noto Color Emoji (Linux). Im Dev-Mode ignorieren. |
| Browser Gamepad API in Gaming Mode | Nicht nutzbar | Steam Input + Flatpak-Sandbox blockiert udev-Events für Chromium. Deshalb `/dev/input/js*` direkt. | | Browser Gamepad API in Gaming Mode | Nicht nutzbar | Steam Input + Flatpak-Sandbox blockiert udev-Events für Chromium. Deshalb evdev direkt im Main-Process. |
| Bluetooth-Controller-Deduplizierung | Aktiv | Mehrere `/dev/input/js*`-Devices melden gleiche Inputs → 40ms-Deduplizierungsfenster in `gamepadReader.ts` | | Bluetooth-Controller-Deduplizierung | Aktiv | Mehrere `/dev/input/js*`-Devices (z.B. Raw-Device + Steam-Virtual-Device) → 40ms-Deduplizierungsfenster in `gamepadReader.ts` |
| Flatpak-Manifest mpv-Einträge | Noch nicht bereinigt | `tv.twitch4steamdeck.App.yml` enthält noch mpv-Build-Schritte und den mpv-Patch — nach Steam Deck Test entfernen. | | Flatpak-Manifest mpv-Einträge | Noch nicht bereinigt | `tv.twitch4steamdeck.App.yml` enthält noch mpv-Build-Schritte und den mpv-Patch — nach Steam Deck Test entfernen. |
| Direkt-Start: kein VOD-Support | By Design | AppShell-Overlay nur für Live-Streams. VODs nur über Kanalseite (ChannelScreen) mit Resume + Kapitel. | | Direkt-Start: kein VOD-Support | By Design | AppShell-Overlay nur für Live-Streams. VODs nur über Kanalseite (ChannelScreen) mit Resume + Kapitel. |
@@ -308,9 +310,10 @@ Beim Start erscheinen diese Logs (alle mit `[VideoPlayer]`-Prefix):
2. hls.js seeked in `MANIFEST_PARSED`-Handler: `video.currentTime = startPosition` 2. hls.js seeked in `MANIFEST_PARSED`-Handler: `video.currentTime = startPosition`
### Gamepad-Eingaben kommen nicht an (Steam Deck) ### Gamepad-Eingaben kommen nicht an (Steam Deck)
1. `gamepadReader.ts` liest `/dev/input/js*` — prüfen ob Device vorhanden 1. `gamepadReader.ts` scannt `/dev/input/js*` — prüfen ob js-Device vorhanden (`ls /dev/input/js*`)
2. Deduplizierungs-Fenster: 40ms — bei Doppel-Inputs erhöhen 2. Für jedes js-Device wird via `/sys/class/input/jsN` ein evdev-Device (`event*`) gesucht — Log `[gamepad] geöffnet (evdev): /dev/input/js0 → /dev/input/event4` zeigt ob das funktioniert
3. Axis-Mapping: DPad ist Axis 6/7 (binary), Stick ist 0/1 (threshold 16384) 3. Deduplizierungs-Fenster: 40ms — bei Doppel-Inputs `DUPLICATE_EVENT_WINDOW_MS` erhöhen
4. Axis-Mapping: DPad ist ABS_HAT0X/Y (code 16/17), Stick ist ABS_X/Y (code 0/1, threshold 16384)
### IPC-Channel fehlt / unbekannt ### IPC-Channel fehlt / unbekannt
Prüfen: `handlers.ts` (Main), `preload/index.ts` (Bridge), `t4sd.d.ts` (Renderer-Typen) — alle drei müssen übereinstimmen. Prüfen: `handlers.ts` (Main), `preload/index.ts` (Bridge), `t4sd.d.ts` (Renderer-Typen) — alle drei müssen übereinstimmen.
+21 -7
View File
@@ -9,6 +9,20 @@
Twitch client for the Steam Deck with Big-Screen UI, full gamepad control, and VOD playback with resume. Twitch client for the Steam Deck with Big-Screen UI, full gamepad control, and VOD playback with resume.
## Screenshots
<p align="center">
<img src="screenshots/browse.jpg" width="49%" alt="Browse screen" />
<img src="screenshots/follow.jpg" width="49%" alt="Following screen" />
</p>
<p align="center">
<img src="screenshots/channelscreen.jpg" width="49%" alt="Channel screen" />
<img src="screenshots/player_overlay.jpg" width="49%" alt="Playback overlay" />
</p>
<p align="center">
<img src="screenshots/chapter.jpg" width="49%" alt="Chapter panel" />
</p>
## Features ## Features
- **Live Streams** — Ad-free playback via Streamlink + hls.js (HTML5 video, no external player) - **Live Streams** — Ad-free playback via Streamlink + hls.js (HTML5 video, no external player)
@@ -16,7 +30,7 @@ Twitch client for the Steam Deck with Big-Screen UI, full gamepad control, and V
- **VOD Chapters** — Browse and jump to chapters within VODs; view count displayed on VOD cards - **VOD Chapters** — Browse and jump to chapters within VODs; view count displayed on VOD cards
- **Video Quality Selection** — Change stream quality on-the-fly during playback (Live and VOD); available qualities fetched dynamically via Streamlink; session-only, defaults to `best` - **Video Quality Selection** — Change stream quality on-the-fly during playback (Live and VOD); available qualities fetched dynamically via Streamlink; session-only, defaults to `best`
- **Direct Stream Start** — Press A on any stream card in Browse/Top Streams/Category to start immediately without leaving the browse view - **Direct Stream Start** — Press A on any stream card in Browse/Top Streams/Category to start immediately without leaving the browse view
- **Gamepad Control** — Full control via Steam Deck controller or Xbox gamepad, including Gaming Mode (reads `/dev/input/js*` directly, bypasses Chromium sandbox limitations) - **Gamepad Control** — Full control via Steam Deck controller, Xbox, or PlayStation gamepad (USB and Bluetooth), including Gaming Mode (reads Linux evdev `/dev/input/event*` directly with standardized BTN_* codes — works with any controller regardless of driver)
- **Big-Screen UI** — 10-foot interface optimized for TV/deck; fills the screen on Steam Deck and on external TVs when docked (React + Electron) - **Big-Screen UI** — 10-foot interface optimized for TV/deck; fills the screen on Steam Deck and on external TVs when docked (React + Electron)
- **Twitch Login** — Device Code Flow with QR code (no browser required) - **Twitch Login** — Device Code Flow with QR code (no browser required)
- **Following** — Live status and overview of followed streamers - **Following** — Live status and overview of followed streamers
@@ -34,7 +48,7 @@ Electron (Main Process)
├── Auth -- Twitch Device Code Flow + token management (Electron safeStorage) ├── Auth -- Twitch Device Code Flow + token management (Electron safeStorage)
├── Helix Client -- Twitch API (followed channels, streams, VODs, categories, chapters via GQL) ├── Helix Client -- Twitch API (followed channels, streams, VODs, categories, chapters via GQL)
├── Playback -- Streamlink --stream-url → HLS URL → IPC event to renderer ├── Playback -- Streamlink --stream-url → HLS URL → IPC event to renderer
├── Gamepad -- Linux joystick API (/dev/input/js*) ├── Gamepad -- Linux evdev (/dev/input/event*), discovered via js*, BTN_* codes
└── History -- SQLite (better-sqlite3) for VOD history + resume └── History -- SQLite (better-sqlite3) for VOD history + resume
Electron (Renderer) Electron (Renderer)
@@ -166,7 +180,7 @@ The script handles automatically:
| Video | hls.js 1.6 (HTML5 `<video>` in Renderer) | | Video | hls.js 1.6 (HTML5 `<video>` in Renderer) |
| Stream URL | Streamlink 8.2.1 (Windows dev) / 6.11.0 (Flatpak) via `--stream-url` | | Stream URL | Streamlink 8.2.1 (Windows dev) / 6.11.0 (Flatpak) via `--stream-url` |
| Database | better-sqlite3 | | Database | better-sqlite3 |
| Gamepad | Linux joystick API (`/dev/input/js*`) | | Gamepad | Linux evdev (`/dev/input/event*`), Xbox / PS / any BT or USB controller |
| Packaging | Flatpak / electron-builder (AppImage) | | Packaging | Flatpak / electron-builder (AppImage) |
| HW Decoding | Chromium VA-API (automatic on Steam Deck) | | HW Decoding | Chromium VA-API (automatic on Steam Deck) |
@@ -183,12 +197,12 @@ The script handles automatically:
## Features ## Features
- **Live-Streams** — Werbefreie Wiedergabe ueber Streamlink + hls.js (HTML5-Video, kein externer Player) - **Live-Streams** — Wiedergabe ueber Streamlink + hls.js (HTML5-Video, kein externer Player)
- **VOD-Wiedergabe** — Aufnahmen ansehen mit automatischem Resume an der letzten Position - **VOD-Wiedergabe** — Aufnahmen ansehen mit automatischem Resume an der letzten Position
- **VOD-Kapitel** — Kapitel in VODs durchsuchen und direkt anspringen; Aufrufzahl auf VOD-Karten - **VOD-Kapitel** — Kapitel in VODs durchsuchen und direkt anspringen; Aufrufzahl auf VOD-Karten
- **Video-Qualitaetswahl** — Stream-Qualitaet waehrend der Wiedergabe aendern (Live und VOD); verfuegbare Qualitaeten werden dynamisch ueber Streamlink ermittelt; nur Session-State, Standard ist `best` - **Video-Qualitaetswahl** — Stream-Qualitaet waehrend der Wiedergabe aendern (Live und VOD); verfuegbare Qualitaeten werden dynamisch ueber Streamlink ermittelt; nur Session-State, Standard ist `best`
- **Direkter Stream-Start** — A-Button auf einer Stream-Karte in Durchsuchen/Top-Streams/Kategorie startet den Stream sofort als Vollbild-Overlay, ohne die Browse-Ansicht zu verlassen - **Direkter Stream-Start** — A-Button auf einer Stream-Karte in Durchsuchen/Top-Streams/Kategorie startet den Stream sofort als Vollbild-Overlay, ohne die Browse-Ansicht zu verlassen
- **Gamepad-Steuerung** — Volle Bedienung per Steam Deck Controller oder Xbox-Gamepad, auch im Gaming Mode (liest `/dev/input/js*` direkt, umgeht Chromium-Sandbox-Limitierungen) - **Gamepad-Steuerung** — Volle Bedienung per Steam Deck Controller, Xbox- oder PlayStation-Gamepad (USB und Bluetooth), auch im Gaming Mode (liest Linux evdev `/dev/input/event*` direkt mit standardisierten BTN_*-Codes — funktioniert mit jedem Controller unabhängig vom Treiber)
- **Big-Screen-UI** — 10-Foot-Interface optimiert fuer TV/Deck; bildschirmfuellend auf dem Steam Deck und auf externen TVs im Docking-Betrieb (React + Electron) - **Big-Screen-UI** — 10-Foot-Interface optimiert fuer TV/Deck; bildschirmfuellend auf dem Steam Deck und auf externen TVs im Docking-Betrieb (React + Electron)
- **Twitch-Login** — Device Code Flow mit QR-Code (kein Browser noetig) - **Twitch-Login** — Device Code Flow mit QR-Code (kein Browser noetig)
- **Gefolgte Kanaele** — Live-Status und Uebersicht der gefolgten Streamer - **Gefolgte Kanaele** — Live-Status und Uebersicht der gefolgten Streamer
@@ -206,7 +220,7 @@ Electron (Main Process)
├── Auth -- Twitch Device Code Flow + Token-Verwaltung (Electron safeStorage) ├── Auth -- Twitch Device Code Flow + Token-Verwaltung (Electron safeStorage)
├── Helix Client -- Twitch API (gefolgte Kanaele, Streams, VODs, Kategorien, Kapitel via GQL) ├── Helix Client -- Twitch API (gefolgte Kanaele, Streams, VODs, Kategorien, Kapitel via GQL)
├── Playback -- Streamlink --stream-url → HLS-URL → IPC-Event an Renderer ├── Playback -- Streamlink --stream-url → HLS-URL → IPC-Event an Renderer
├── Gamepad -- Linux joystick API (/dev/input/js*) ├── Gamepad -- Linux evdev (/dev/input/event*), via js* erkannt, BTN_*-Codes
└── History -- SQLite (better-sqlite3) fuer VOD-Verlauf + Resume └── History -- SQLite (better-sqlite3) fuer VOD-Verlauf + Resume
Electron (Renderer) Electron (Renderer)
@@ -338,7 +352,7 @@ Das Script erledigt automatisch:
| Video | hls.js 1.6 (HTML5 `<video>` im Renderer) | | Video | hls.js 1.6 (HTML5 `<video>` im Renderer) |
| Stream-URL | Streamlink 8.2.1 (Windows-Dev) / 6.11.0 (Flatpak) via `--stream-url` | | Stream-URL | Streamlink 8.2.1 (Windows-Dev) / 6.11.0 (Flatpak) via `--stream-url` |
| Datenbank | better-sqlite3 | | Datenbank | better-sqlite3 |
| Gamepad | Linux joystick API (`/dev/input/js*`) | | Gamepad | Linux evdev (`/dev/input/event*`), Xbox / PS / beliebiger BT- oder USB-Controller |
| Packaging | Flatpak / electron-builder (AppImage) | | Packaging | Flatpak / electron-builder (AppImage) |
| HW-Decoding | Chromium VA-API (automatisch auf Steam Deck) | | HW-Decoding | Chromium VA-API (automatisch auf Steam Deck) |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "twitch4steamdeck", "name": "twitch4steamdeck",
"version": "0.4.2", "version": "0.4.3",
"description": "Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)", "description": "Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)",
"main": "out/main/index.js", "main": "out/main/index.js",
"author": "twitch4steamdeck", "author": "twitch4steamdeck",
Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

+272 -79
View File
@@ -1,43 +1,84 @@
/** /**
* Liest Gamepad-Events direkt von /dev/input/js* (Linux joystick API). * Liest Gamepad-Events via Linux evdev (/dev/input/event*).
* Umgeht Chromiums Gamepad API, die in Flatpak/Gaming Mode nicht funktioniert, *
* weil Steam Input den Controller virtualisiert und udev-Events nicht * Erkennt Controller über /dev/input/js* und öffnet deren zugehöriges
* in die Sandbox propagieren. * event*-Interface. Das evdev-Interface liefert standardisierte, benannte
* Button-Codes (BTN_SOUTH, BTN_NORTH, …) die über alle Controller-Typen
* (Xbox, PlayStation, Nintendo) und Verbindungsarten (USB, Bluetooth)
* konsistent sind – unabhängig davon, welchen Treiber der Kernel nutzt.
*
* Damit entfällt das alte Problem mit der button-Nummerierung im Joystick-API,
* bei dem Xbox-One-Controller via Bluetooth (Share-Button verschiebt die
* Nummerierung) X-Button als Nummer 3 statt 2 lieferten.
* *
* Unterstützt mehrere Gamepads gleichzeitig (Steam Deck + Bluetooth Xbox). * Unterstützt mehrere Gamepads gleichzeitig (Steam Deck + Bluetooth Xbox).
* Hotplug wird durch periodisches Scannen von /dev/input/ abgedeckt. * Hotplug wird durch periodisches Scannen von /dev/input/ abgedeckt.
* *
* Xbox 360 / Steam Virtual Controller Joystick-Mapping: * BTN_*-Codes sind konsistent für alle Controller:
* Buttons: 0=A, 1=B, 2=X, 3=Y, 4=LB, 5=RB, 6=Back, 7=Start * BTN_SOUTH (0x130) = A / Cross
* Axes: 0=LStickX, 1=LStickY, 2=LTrigger, 3=RStickX, 4=RStickY, 5=RTrigger, 6=DPadX, 7=DPadY * BTN_EAST (0x131) = B / Circle
* BTN_NORTH (0x133) = X / Square
* BTN_WEST (0x134) = Y / Triangle
* BTN_TL (0x136) = LB / L1
* BTN_TR (0x137) = RB / R1
* BTN_TL2 (0x138) = LT / L2 (als Button bei manchen Controllern)
* BTN_TR2 (0x139) = RT / R2 (als Button bei manchen Controllern)
* ABS_HAT0X (16) = D-Pad X-Achse
* ABS_HAT0Y (17) = D-Pad Y-Achse
* ABS_X/Y (0/1) = Linker Stick
* ABS_Z/RZ (2/5) = LT/RT als Analog-Achse
*/ */
import * as fs from 'node:fs' import * as fs from 'node:fs'
import * as path from 'node:path' import * as path from 'node:path'
import type { BrowserWindow } from 'electron' import type { BrowserWindow } from 'electron'
// js_event: { u32 time, s16 value, u8 type, u8 number } = 8 bytes // evdev input_event auf 64-bit Linux:
const JS_EVENT_BUTTON = 0x01 // { u64 tv_sec (8 Bytes), u64 tv_usec (8 Bytes), u16 type (2), u16 code (2), s32 value (4) } = 24 Bytes
const JS_EVENT_AXIS = 0x02 const INPUT_EVENT_SIZE = 24
const JS_EVENT_INIT = 0x80
const EV_KEY = 0x01
const EV_ABS = 0x03
// Button-Codes (BTN_GAMEPAD-Bereich) – konsistent über alle Controller und Treiber
const BTN_SOUTH = 0x130
const BTN_EAST = 0x131
const BTN_NORTH = 0x133
const BTN_WEST = 0x134
const BTN_TL = 0x136
const BTN_TR = 0x137
const BTN_TL2 = 0x138
const BTN_TR2 = 0x139
// ABS-Achsen-Codes
const ABS_X = 0
const ABS_Y = 1
const ABS_Z = 2 // LT / L2
const ABS_RZ = 5 // RT / R2
const ABS_HAT0X = 16 // D-Pad X
const ABS_HAT0Y = 17 // D-Pad Y
const BUTTON_MAP: Record<number, string> = { const BUTTON_MAP: Record<number, string> = {
0: 'Enter', // A [BTN_SOUTH]: 'Enter',
1: 'Escape', // B [BTN_EAST]: 'Escape',
2: 'x', // X [BTN_NORTH]: 'x',
3: 'y', // Y [BTN_WEST]: 'y',
4: 'l1', // LB / L1 [BTN_TL]: 'l1',
5: 'r1' // RB / R1 [BTN_TR]: 'r1',
[BTN_TL2]: 'l2',
[BTN_TR2]: 'r2',
} }
const STICK_THRESHOLD = 16384 // ~50% von 32767 const STICK_THRESHOLD = 16384 // ~50% von 32767
// Trigger-Schwellwert: Achsenwert > 0 (nicht gedrückt) bis ~255–1023 je Controller
const TRIGGER_THRESHOLD = 8
const REPEAT_INITIAL_MS = 400 const REPEAT_INITIAL_MS = 400
const REPEAT_INTERVAL_MS = 150 const REPEAT_INTERVAL_MS = 150
// L2/R2 Trigger: kein Auto-Repeat, da 300s-Spruenge nicht wiederholt werden sollen. // Trigger-Achsen sollen kein Auto-Repeat auslösen (300s-Sprünge nicht wiederholen)
const NO_REPEAT_AXES = new Set([2, 5]) const NO_REPEAT_ABS_CODES = new Set([ABS_Z, ABS_RZ])
const SCAN_INTERVAL_MS = 3000 const SCAN_INTERVAL_MS = 3000
// Bluetooth-/Steam-Setups liefern dieselbe Eingabe teils über mehrere js*-Devices. // Bluetooth-/Steam-Setups können dieselbe Eingabe über mehrere Devices liefern.
// Nahezu zeitgleiche identische Events werden deshalb global entdoppelt. // Nahezu zeitgleiche identische Events werden global entdoppelt.
const DUPLICATE_EVENT_WINDOW_MS = 40 const DUPLICATE_EVENT_WINDOW_MS = 40
interface AxisState { interface AxisState {
@@ -46,26 +87,45 @@ interface AxisState {
interval: ReturnType<typeof setInterval> | null interval: ReturnType<typeof setInterval> | null
} }
class JoystickReader { /**
* Sucht das zugehörige evdev-Device (/dev/input/eventN) für ein
* Joystick-Device (/dev/input/jsN) über den /sys-Dateisystem-Pfad.
*/
function findEventDevice(jsDevicePath: string): string | null {
try {
const jsName = path.basename(jsDevicePath) // 'js0'
const sysLink = `/sys/class/input/${jsName}`
const realPath = fs.realpathSync(sysLink) // .../input/input7/js0
const inputDir = path.dirname(realPath) // .../input/input7
const entries = fs.readdirSync(inputDir)
const eventEntry = entries.find((e) => /^event\d+$/.test(e))
if (eventEntry) return `/dev/input/${eventEntry}`
} catch {
// Kein /sys-Zugriff oder Device verschwunden
}
return null
}
class EvdevReader {
private stream: fs.ReadStream | null = null private stream: fs.ReadStream | null = null
private buf = Buffer.alloc(0) private buf = Buffer.alloc(0)
private axes = new Map<number, AxisState>() private axes = new Map<number, AxisState>()
private alive = true private alive = true
constructor( constructor(
readonly devicePath: string, readonly eventPath: string,
private onKey: (key: string) => void private onKey: (key: string) => void
) {} ) {}
start(): boolean { start(): boolean {
try { try {
this.stream = fs.createReadStream(this.devicePath) this.stream = fs.createReadStream(this.eventPath)
this.stream.on('data', (chunk) => { this.stream.on('data', (chunk) => {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
this.buf = Buffer.concat([this.buf, buf]) this.buf = Buffer.concat([this.buf, buf])
while (this.buf.length >= 8) { while (this.buf.length >= INPUT_EVENT_SIZE) {
this.processEvent(this.buf.subarray(0, 8)) this.processEvent(this.buf.subarray(0, INPUT_EVENT_SIZE))
this.buf = this.buf.subarray(8) this.buf = this.buf.subarray(INPUT_EVENT_SIZE)
} }
}) })
this.stream.on('error', () => { this.stream.on('error', () => {
@@ -97,66 +157,60 @@ class JoystickReader {
} }
private processEvent(raw: Buffer): void { private processEvent(raw: Buffer): void {
const value = raw.readInt16LE(4) // Offsets in 24-Byte input_event: type @ 16, code @ 18, value @ 20
const rawType = raw.readUInt8(6) const type = raw.readUInt16LE(16)
const number = raw.readUInt8(7) const code = raw.readUInt16LE(18)
const isInit = (rawType & JS_EVENT_INIT) !== 0 const value = raw.readInt32LE(20)
const type = rawType & ~JS_EVENT_INIT
if (type === JS_EVENT_BUTTON && !isInit) { if (type === EV_KEY) {
if (value === 1) { if (value === 1) {
const key = BUTTON_MAP[number] const key = BUTTON_MAP[code]
if (key) this.onKey(key) if (key) this.onKey(key)
} }
} else if (type === JS_EVENT_AXIS) { } else if (type === EV_ABS) {
this.processAxis(number, value, isInit) this.processAxis(code, value)
} }
} }
private processAxis(axis: number, value: number, isInit: boolean): void { private processAxis(code: number, value: number): void {
let key: string | null = null let key: string | null = null
// D-Pad (Axes 6/7): binäre Werte (-32767, 0, 32767) if (code === ABS_HAT0X) {
if (axis === 6) { if (value < 0) key = 'ArrowLeft'
if (value < 0) key = 'ArrowLeft'
else if (value > 0) key = 'ArrowRight' else if (value > 0) key = 'ArrowRight'
} else if (axis === 7) { } else if (code === ABS_HAT0Y) {
if (value < 0) key = 'ArrowUp' if (value < 0) key = 'ArrowUp'
else if (value > 0) key = 'ArrowDown' else if (value > 0) key = 'ArrowDown'
} } else if (code === ABS_X) {
// Left Stick (Axes 0/1): analoge Werte mit Deadzone if (value < -STICK_THRESHOLD) key = 'ArrowLeft'
else if (axis === 0) {
if (value < -STICK_THRESHOLD) key = 'ArrowLeft'
else if (value > STICK_THRESHOLD) key = 'ArrowRight' else if (value > STICK_THRESHOLD) key = 'ArrowRight'
} else if (axis === 1) { } else if (code === ABS_Y) {
if (value < -STICK_THRESHOLD) key = 'ArrowUp' if (value < -STICK_THRESHOLD) key = 'ArrowUp'
else if (value > STICK_THRESHOLD) key = 'ArrowDown' else if (value > STICK_THRESHOLD) key = 'ArrowDown'
} } else if (code === ABS_Z) {
// Trigger (Axes 2/5): -32767 = nicht gedrückt, +32767 = voll gedrückt if (value > TRIGGER_THRESHOLD) key = 'l2'
else if (axis === 2) { } else if (code === ABS_RZ) {
if (value > 0) key = 'l2' if (value > TRIGGER_THRESHOLD) key = 'r2'
} else if (axis === 5) {
if (value > 0) key = 'r2'
} else { } else {
return return
} }
let state = this.axes.get(axis) let state = this.axes.get(code)
if (!state) { if (!state) {
state = { key: null, timer: null, interval: null } state = { key: null, timer: null, interval: null }
this.axes.set(axis, state) this.axes.set(code, state)
} }
if (key !== state.key) { if (key !== state.key) {
if (state.timer) clearTimeout(state.timer) if (state.timer) clearTimeout(state.timer)
if (state.interval) clearInterval(state.interval) if (state.interval) clearInterval(state.interval)
state.timer = null state.timer = null
state.interval = null state.interval = null
state.key = key state.key = key
if (key && !isInit) { if (key) {
this.onKey(key) this.onKey(key)
if (!NO_REPEAT_AXES.has(axis)) { if (!NO_REPEAT_ABS_CODES.has(code)) {
const k = key const k = key
state.timer = setTimeout(() => { state.timer = setTimeout(() => {
if (state!.key === k) { if (state!.key === k) {
@@ -173,13 +227,141 @@ class JoystickReader {
} }
/** /**
* Startet das Lesen aller /dev/input/js* Devices. * Fallback-Reader für den Fall, dass kein evdev-Device gefunden wird.
* Nutzt das alte Joystick-API (8-Byte-Events, Joystick-Nummern-Mapping).
* Deckt Controller ab, die kein /sys-Event-Device-Mapping haben.
*/
class JoystickFallbackReader {
private stream: fs.ReadStream | null = null
private buf = Buffer.alloc(0)
private axes = new Map<number, AxisState>()
private alive = true
private static readonly BUTTON_MAP: Record<number, string> = {
0: 'Enter',
1: 'Escape',
2: 'x',
3: 'y',
4: 'l1',
5: 'r1',
}
constructor(
readonly devicePath: string,
private onKey: (key: string) => void
) {}
start(): boolean {
try {
this.stream = fs.createReadStream(this.devicePath)
this.stream.on('data', (chunk) => {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
this.buf = Buffer.concat([this.buf, buf])
while (this.buf.length >= 8) {
this.processEvent(this.buf.subarray(0, 8))
this.buf = this.buf.subarray(8)
}
})
this.stream.on('error', () => { this.alive = false; this.stop() })
this.stream.on('close', () => { this.alive = false })
return true
} catch {
this.alive = false
return false
}
}
stop(): void {
this.stream?.destroy()
this.stream = null
for (const state of this.axes.values()) {
if (state.timer) clearTimeout(state.timer)
if (state.interval) clearInterval(state.interval)
}
this.axes.clear()
}
isAlive(): boolean { return this.alive }
private processEvent(raw: Buffer): void {
const value = raw.readInt16LE(4)
const rawType = raw.readUInt8(6)
const number = raw.readUInt8(7)
const isInit = (rawType & 0x80) !== 0
const type = rawType & ~0x80
if (type === 0x01 && !isInit) {
if (value === 1) {
const key = JoystickFallbackReader.BUTTON_MAP[number]
if (key) this.onKey(key)
}
} else if (type === 0x02) {
this.processAxis(number, value, isInit)
}
}
private processAxis(axis: number, value: number, isInit: boolean): void {
let key: string | null = null
if (axis === 6) {
if (value < 0) key = 'ArrowLeft'
else if (value > 0) key = 'ArrowRight'
} else if (axis === 7) {
if (value < 0) key = 'ArrowUp'
else if (value > 0) key = 'ArrowDown'
} else if (axis === 0) {
if (value < -STICK_THRESHOLD) key = 'ArrowLeft'
else if (value > STICK_THRESHOLD) key = 'ArrowRight'
} else if (axis === 1) {
if (value < -STICK_THRESHOLD) key = 'ArrowUp'
else if (value > STICK_THRESHOLD) key = 'ArrowDown'
} else if (axis === 2) {
if (value > 0) key = 'l2'
} else if (axis === 5) {
if (value > 0) key = 'r2'
} else {
return
}
let state = this.axes.get(axis)
if (!state) {
state = { key: null, timer: null, interval: null }
this.axes.set(axis, state)
}
if (key !== state.key) {
if (state.timer) clearTimeout(state.timer)
if (state.interval) clearInterval(state.interval)
state.timer = null
state.interval = null
state.key = key
if (key && !isInit) {
this.onKey(key)
if (axis !== 2 && axis !== 5) {
const k = key
state.timer = setTimeout(() => {
if (state!.key === k) {
this.onKey(k)
state!.interval = setInterval(() => {
if (state!.key === k) this.onKey(k)
}, REPEAT_INTERVAL_MS)
}
}, REPEAT_INITIAL_MS)
}
}
}
}
}
/**
* Startet das Lesen aller /dev/input/js* Devices (evdev-Interface bevorzugt).
* Gibt eine Cleanup-Funktion zurück. * Gibt eine Cleanup-Funktion zurück.
*/ */
export function startGamepadReader(getWindow: () => BrowserWindow | null): () => void { export function startGamepadReader(getWindow: () => BrowserWindow | null): () => void {
if (process.platform !== 'linux') return () => {} if (process.platform !== 'linux') return () => {}
const readers = new Map<string, JoystickReader>() // Keyed by js-Device-Pfad
const readers = new Map<string, EvdevReader | JoystickFallbackReader>()
const lastKeyAt = new Map<string, number>() const lastKeyAt = new Map<string, number>()
const onKey = (key: string): void => { const onKey = (key: string): void => {
@@ -201,31 +383,42 @@ export function startGamepadReader(getWindow: () => BrowserWindow | null): () =>
for (const entry of entries) { for (const entry of entries) {
if (!entry.startsWith('js')) continue if (!entry.startsWith('js')) continue
const devPath = path.join(inputDir, entry) const jsPath = path.join(inputDir, entry)
seen.add(devPath) seen.add(jsPath)
if (readers.has(devPath)) { if (readers.has(jsPath)) {
if (!readers.get(devPath)!.isAlive()) { if (!readers.get(jsPath)!.isAlive()) {
readers.get(devPath)!.stop() readers.get(jsPath)!.stop()
readers.delete(devPath) readers.delete(jsPath)
} else { } else {
continue continue
} }
} }
const reader = new JoystickReader(devPath, onKey) const eventPath = findEventDevice(jsPath)
if (reader.start()) { let reader: EvdevReader | JoystickFallbackReader
readers.set(devPath, reader)
console.log(`[gamepad] geöffnet: ${devPath}`) if (eventPath) {
reader = new EvdevReader(eventPath, onKey)
if (reader.start()) {
readers.set(jsPath, reader)
console.log(`[gamepad] geöffnet (evdev): ${jsPath} → ${eventPath}`)
}
} else {
// Fallback auf altes Joystick-API wenn kein /sys-Mapping gefunden
reader = new JoystickFallbackReader(jsPath, onKey)
if (reader.start()) {
readers.set(jsPath, reader)
console.log(`[gamepad] geöffnet (js-fallback): ${jsPath}`)
}
} }
} }
// Entfernte Devices aufräumen for (const [jsPath, reader] of readers) {
for (const [devPath, reader] of readers) { if (!seen.has(jsPath)) {
if (!seen.has(devPath)) { console.log(`[gamepad] getrennt: ${jsPath}`)
console.log(`[gamepad] getrennt: ${devPath}`)
reader.stop() reader.stop()
readers.delete(devPath) readers.delete(jsPath)
} }
} }
} catch { } catch {