diff --git a/flatpak/tv.twitch4steamdeck.App.yml b/flatpak/tv.twitch4steamdeck.App.yml index d0b22cf..bb6e22d 100644 --- a/flatpak/tv.twitch4steamdeck.App.yml +++ b/flatpak/tv.twitch4steamdeck.App.yml @@ -14,7 +14,7 @@ finish-args: - --socket=wayland - --socket=fallback-x11 - --socket=pulseaudio # Audio für mpv - - --device=dri # GPU-Zugriff für VAAPI-Hardware-Decoding + - --device=all # GPU + /dev/input/* (Gamepad API braucht Input-Devices) - --talk-name=org.freedesktop.Notifications # Electron userData liegt automatisch in ~/.var/app/tv.twitch4steamdeck.App/config/ # Kein extra --filesystem nötig. diff --git a/src/main/index.ts b/src/main/index.ts index 6426272..013023e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,6 +4,7 @@ import { AuthService } from './auth/authService' import { HelixClient } from './twitch/helixClient' import { PlaybackService } from './playback/playbackService' import { registerIpcHandlers } from './ipc/handlers' +import { startGamepadReader } from './input/gamepadReader' const isDev = !app.isPackaged @@ -49,7 +50,10 @@ app.whenReady().then(async () => { registerIpcHandlers(auth, helix, playback) + const stopGamepad = startGamepadReader(() => BrowserWindow.getAllWindows()[0] ?? null) + app.on('before-quit', () => { + stopGamepad() playback.stopAll() }) diff --git a/src/main/input/gamepadReader.ts b/src/main/input/gamepadReader.ts new file mode 100644 index 0000000..1a19658 --- /dev/null +++ b/src/main/input/gamepadReader.ts @@ -0,0 +1,222 @@ +/** + * Liest Gamepad-Events direkt von /dev/input/js* (Linux joystick API). + * Umgeht Chromiums Gamepad API, die in Flatpak/Gaming Mode nicht funktioniert, + * weil Steam Input den Controller virtualisiert und udev-Events nicht + * in die Sandbox propagieren. + * + * Unterstützt mehrere Gamepads gleichzeitig (Steam Deck + Bluetooth Xbox). + * Hotplug wird durch periodisches Scannen von /dev/input/ abgedeckt. + * + * Xbox 360 / Steam Virtual Controller Joystick-Mapping: + * Buttons: 0=A, 1=B, 2=X, 3=Y, 4=LB, 5=RB, 6=Back, 7=Start + * Axes: 0=LStickX, 1=LStickY, 2=LTrigger, 3=RStickX, 4=RStickY, 5=RTrigger, 6=DPadX, 7=DPadY + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' +import type { BrowserWindow } from 'electron' + +// js_event: { u32 time, s16 value, u8 type, u8 number } = 8 bytes +const JS_EVENT_BUTTON = 0x01 +const JS_EVENT_AXIS = 0x02 +const JS_EVENT_INIT = 0x80 + +const BUTTON_MAP: Record = { + 0: 'Enter', // A + 1: 'Escape', // B + 2: 'x', // X + 3: 'y' // Y +} + +const STICK_THRESHOLD = 16384 // ~50% von 32767 +const REPEAT_INITIAL_MS = 400 +const REPEAT_INTERVAL_MS = 150 +const SCAN_INTERVAL_MS = 3000 + +interface AxisState { + key: string | null + timer: ReturnType | null + interval: ReturnType | null +} + +class JoystickReader { + private stream: fs.ReadStream | null = null + private buf = Buffer.alloc(0) + private axes = new Map() + private alive = true + + constructor( + readonly devicePath: string, + private onKey: (key: string) => void + ) {} + + start(): boolean { + try { + this.stream = fs.createReadStream(this.devicePath) + this.stream.on('data', (chunk: Buffer) => { + this.buf = Buffer.concat([this.buf, chunk]) + 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 & JS_EVENT_INIT) !== 0 + const type = rawType & ~JS_EVENT_INIT + + if (type === JS_EVENT_BUTTON && !isInit) { + if (value === 1) { + const key = BUTTON_MAP[number] + if (key) this.onKey(key) + } + } else if (type === JS_EVENT_AXIS) { + this.processAxis(number, value, isInit) + } + } + + private processAxis(axis: number, value: number, isInit: boolean): void { + let key: string | null = null + + // D-Pad (Axes 6/7): binäre Werte (-32767, 0, 32767) + 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' + } + // Left Stick (Axes 0/1): analoge Werte mit Deadzone + 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 { + 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) + 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. + * Gibt eine Cleanup-Funktion zurück. + */ +export function startGamepadReader(getWindow: () => BrowserWindow | null): () => void { + if (process.platform !== 'linux') return () => {} + + const readers = new Map() + + const onKey = (key: string): void => { + const win = getWindow() + if (!win || win.isDestroyed()) return + win.webContents.send('gamepad-input', key) + } + + const scanDevices = (): void => { + const inputDir = '/dev/input' + try { + const entries = fs.readdirSync(inputDir) + const seen = new Set() + + for (const entry of entries) { + if (!entry.startsWith('js')) continue + const devPath = path.join(inputDir, entry) + seen.add(devPath) + + if (readers.has(devPath)) { + if (!readers.get(devPath)!.isAlive()) { + readers.get(devPath)!.stop() + readers.delete(devPath) + } else { + continue + } + } + + const reader = new JoystickReader(devPath, onKey) + if (reader.start()) { + readers.set(devPath, reader) + console.log(`[gamepad] geöffnet: ${devPath}`) + } + } + + // Entfernte Devices aufräumen + for (const [devPath, reader] of readers) { + if (!seen.has(devPath)) { + console.log(`[gamepad] getrennt: ${devPath}`) + reader.stop() + readers.delete(devPath) + } + } + } catch { + // /dev/input nicht zugänglich + } + } + + scanDevices() + const timer = setInterval(scanDevices, SCAN_INTERVAL_MS) + + return () => { + clearInterval(timer) + for (const reader of readers.values()) reader.stop() + readers.clear() + } +} diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index c299195..f1c2106 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -78,6 +78,11 @@ export function registerIpcHandlers( playback.on('playback-event', (event: PlaybackEvent) => { for (const win of BrowserWindow.getAllWindows()) { win.webContents.send(IPC.playbackEvent, event) + // Auf Windows stiehlt mpv den OS-Fokus → Electron refokussieren, + // damit navigator.getGamepads() weiter Daten liefert. + if (process.platform === 'win32' && event.kind === 'started') { + win.focus() + } } }) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 72cd61a..c51fea5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,7 +17,9 @@ const IPC = { playbackStartLive: 'playback:start-live', playbackStartVod: 'playback:start-vod', playbackStop: 'playback:stop', - playbackEvent: 'playback:event' + playbackEvent: 'playback:event', + + gamepadInput: 'gamepad-input' } as const export type AuthStatus = 'logged-out' | 'logged-in' @@ -117,6 +119,13 @@ const api = { ipcRenderer.on(IPC.playbackEvent, listener) return () => ipcRenderer.removeListener(IPC.playbackEvent, listener) } + }, + gamepad: { + onInput: (cb: (key: string) => void): (() => void) => { + const listener = (_e: IpcRendererEvent, key: string): void => cb(key) + ipcRenderer.on(IPC.gamepadInput, listener) + return () => ipcRenderer.removeListener(IPC.gamepadInput, listener) + } } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 50786bf..3b3ce2f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -20,11 +20,20 @@ export default function App(): JSX.Element { return off }, [refresh]) + // Browser Gamepad API (funktioniert im Desktop-Modus / Windows) useEffect(() => { gamepadService.start() return () => gamepadService.stop() }, []) + // Main-Process Gamepad Reader (liest /dev/input/js* direkt, funktioniert in Gaming Mode) + useEffect(() => { + return window.t4sd.gamepad.onInput((key) => { + const target = document.activeElement ?? document.body + target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) + }) + }, []) + if (status === 'loading') { return (
diff --git a/src/renderer/src/input/gamepad.ts b/src/renderer/src/input/gamepad.ts index 8d24dca..76f1eb5 100644 --- a/src/renderer/src/input/gamepad.ts +++ b/src/renderer/src/input/gamepad.ts @@ -3,6 +3,8 @@ * dispatcht synthetische KeyboardEvents, sodass die Spatial-Navigation * ohne Gamepad-spezifischen Code in den Komponenten auskommt. * + * Unterstützt mehrere Gamepads gleichzeitig (z.B. Steam Deck + Xbox BT). + * * Standard-Mapping (Xbox / Steam Deck): * Button 12 = DPad Up → ArrowUp * Button 13 = DPad Down → ArrowDown @@ -10,15 +12,12 @@ * Button 15 = DPad Right → ArrowRight * Button 0 = A → Enter * Button 1 = B → Escape + * Button 2 = X → x + * Button 3 = Y → y * Axis 1 (left stick Y) → Up/Down * Axis 0 (left stick X) → Left/Right */ -interface ButtonState { - pressed: boolean - lastPressed: boolean -} - const BUTTON_MAP: Record = { 0: 'Enter', // A 1: 'Escape', // B @@ -40,16 +39,19 @@ interface AxisRepeat { lastRepeat: number } +interface PadState { + buttons: Map + axes: [AxisRepeat, AxisRepeat] +} + export class GamepadService { private rafId: number | null = null - private buttonStates = new Map() - private axisRepeat: [AxisRepeat, AxisRepeat] = [ - { key: null, heldSince: 0, lastRepeat: 0 }, - { key: null, heldSince: 0, lastRepeat: 0 } - ] + private padStates = new Map() start(): void { if (this.rafId !== null) return + window.addEventListener('gamepadconnected', this.onConnected) + window.addEventListener('gamepaddisconnected', this.onDisconnected) this.poll() } @@ -58,29 +60,62 @@ export class GamepadService { cancelAnimationFrame(this.rafId) this.rafId = null } + window.removeEventListener('gamepadconnected', this.onConnected) + window.removeEventListener('gamepaddisconnected', this.onDisconnected) + this.padStates.clear() + } + + private onConnected = (e: GamepadEvent): void => { + console.log(`[gamepad] connected: ${e.gamepad.id} (index ${e.gamepad.index})`) + } + + private onDisconnected = (e: GamepadEvent): void => { + console.log(`[gamepad] disconnected: ${e.gamepad.id} (index ${e.gamepad.index})`) + this.padStates.delete(e.gamepad.index) + } + + private getState(index: number): PadState { + let state = this.padStates.get(index) + if (!state) { + state = { + buttons: new Map(), + axes: [ + { key: null, heldSince: 0, lastRepeat: 0 }, + { key: null, heldSince: 0, lastRepeat: 0 } + ] + } + this.padStates.set(index, state) + } + return state } private poll = (): void => { this.rafId = requestAnimationFrame(this.poll) const gamepads = navigator.getGamepads() - const gp = gamepads[0] // erstes angeschlossenes Gamepad - if (!gp) return - const now = performance.now() + for (const gp of gamepads) { + if (!gp) continue + this.processPad(gp, now) + } + } + + private processPad(gp: Gamepad, now: number): void { + const state = this.getState(gp.index) + // Buttons for (const [index, key] of Object.entries(BUTTON_MAP)) { const i = Number(index) const btn = gp.buttons[i] if (!btn) continue - const prev = this.buttonStates.get(i) ?? { pressed: false, lastPressed: false } - if (btn.pressed && !prev.pressed) { + const wasPressed = state.buttons.get(i) ?? false + if (btn.pressed && !wasPressed) { this.dispatch(key) } - this.buttonStates.set(i, { pressed: btn.pressed, lastPressed: prev.pressed }) + state.buttons.set(i, btn.pressed) } - // Achsen (nur Axis 0 + 1 = linker Stick) + // Axes (0 = left stick X, 1 = left stick Y) const axisDefs: [number, string, string][] = [ [0, 'ArrowLeft', 'ArrowRight'], [1, 'ArrowUp', 'ArrowDown'] @@ -88,24 +123,24 @@ export class GamepadService { for (let a = 0; a < 2; a++) { const [axisIdx, negKey, posKey] = axisDefs[a] const val = gp.axes[axisIdx] ?? 0 - const state = this.axisRepeat[a] + const ar = state.axes[a] let activeKey: string | null = null if (val < -AXIS_THRESHOLD) activeKey = negKey else if (val > AXIS_THRESHOLD) activeKey = posKey - if (activeKey !== state.key) { - state.key = activeKey - state.heldSince = now - state.lastRepeat = 0 + if (activeKey !== ar.key) { + ar.key = activeKey + ar.heldSince = now + ar.lastRepeat = 0 if (activeKey) this.dispatch(activeKey) } else if (activeKey) { - const held = now - state.heldSince + const held = now - ar.heldSince if (held > REPEAT_INITIAL_MS) { - if (state.lastRepeat === 0) state.lastRepeat = now - if (now - state.lastRepeat > REPEAT_INTERVAL_MS) { + if (ar.lastRepeat === 0) ar.lastRepeat = now + if (now - ar.lastRepeat > REPEAT_INTERVAL_MS) { this.dispatch(activeKey) - state.lastRepeat = now + ar.lastRepeat = now } } } diff --git a/src/renderer/src/screens/BrowseScreen.tsx b/src/renderer/src/screens/BrowseScreen.tsx index 4ea4bca..e934e6f 100644 --- a/src/renderer/src/screens/BrowseScreen.tsx +++ b/src/renderer/src/screens/BrowseScreen.tsx @@ -39,6 +39,7 @@ export default function BrowseScreen({ const [gridIndex, setGridIndex] = useState(0) const [nextCursor, setNextCursor] = useState(undefined) const [isLoadingMore, setIsLoadingMore] = useState(false) + const [isPlaying, setIsPlaying] = useState(false) const gridRef = useRef(null) const load = useCallback(async () => { @@ -80,6 +81,13 @@ export default function BrowseScreen({ void load() }, [load]) + useEffect(() => { + return window.t4sd.playback.onEvent((ev) => { + if (ev.kind === 'started') setIsPlaying(true) + else if (ev.kind === 'stopped' || ev.kind === 'error') setIsPlaying(false) + }) + }, []) + // Nächste Seite automatisch nachladen wenn letztes Element fokussiert wird useEffect(() => { if (focusRegion !== 'grid' || loadState !== 'ok') return @@ -137,6 +145,12 @@ export default function BrowseScreen({ if (ch) onSelectChannel(ch) break } + case 'Escape': { + e.preventDefault() + if (isPlaying) void window.t4sd.playback.stop() + else onRequestSidebar() + break + } } } else { // grid region @@ -170,13 +184,19 @@ export default function BrowseScreen({ if (game) onSelectCategory(game) break } + case 'Escape': { + e.preventDefault() + if (isPlaying) void window.t4sd.playback.stop() + else onRequestSidebar() + break + } } } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [hasFocus, loadState, focusRegion, shelfIndex, gridIndex, topStreams, topGames, getGridColumns, onRequestSidebar, onSelectChannel, onSelectCategory, load]) + }, [hasFocus, loadState, focusRegion, shelfIndex, gridIndex, topStreams, topGames, getGridColumns, onRequestSidebar, onSelectChannel, onSelectCategory, load, isPlaying]) // Grid-Karte in den Viewport scrollen wenn fokussiert useEffect(() => { diff --git a/src/renderer/src/screens/ChannelScreen.tsx b/src/renderer/src/screens/ChannelScreen.tsx index 4753cf9..e13fcbe 100644 --- a/src/renderer/src/screens/ChannelScreen.tsx +++ b/src/renderer/src/screens/ChannelScreen.tsx @@ -78,6 +78,13 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { e.preventDefault() setFocusRegion('shelf') setShelfIndex(0) + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + if (playState === 'playing' || playState === 'starting') { + void window.t4sd.playback.stop() + } else if (channel.isLive) { + void handleWatch() + } } else if (e.key === 'Escape') { e.preventDefault() if (playState === 'playing' || playState === 'starting') { diff --git a/src/renderer/src/screens/FollowingScreen.tsx b/src/renderer/src/screens/FollowingScreen.tsx index c16a794..46b7484 100644 --- a/src/renderer/src/screens/FollowingScreen.tsx +++ b/src/renderer/src/screens/FollowingScreen.tsx @@ -90,12 +90,22 @@ export default function FollowingScreen({ e.preventDefault() setFocusedIndex((i) => Math.max(i - cols, 0)) break + case 'Enter': { + e.preventDefault() + const ch = channels[focusedIndex] + if (ch) onSelectChannel(ch) + break + } + case 'Escape': + e.preventDefault() + onRequestSidebar() + break } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [hasFocus, loadState, channels.length, getColumns, onRequestSidebar, load]) + }, [hasFocus, loadState, channels, focusedIndex, getColumns, onRequestSidebar, load]) const liveCount = channels.filter((c) => c.isLive).length diff --git a/src/renderer/src/types/t4sd.d.ts b/src/renderer/src/types/t4sd.d.ts index 390cb57..e98414c 100644 --- a/src/renderer/src/types/t4sd.d.ts +++ b/src/renderer/src/types/t4sd.d.ts @@ -83,6 +83,9 @@ export interface T4sdApi { stop: () => Promise onEvent: (cb: (event: PlaybackEvent) => void) => () => void } + gamepad: { + onInput: (cb: (key: string) => void) => () => void + } } declare global {