mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
Phase 6 Fix 3
- Gamepad steamdeck repariert - Navigation , Start und Stopp der Wiedergabe ist jetzt möglich
This commit is contained in:
@@ -14,7 +14,7 @@ finish-args:
|
|||||||
- --socket=wayland
|
- --socket=wayland
|
||||||
- --socket=fallback-x11
|
- --socket=fallback-x11
|
||||||
- --socket=pulseaudio # Audio für mpv
|
- --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
|
- --talk-name=org.freedesktop.Notifications
|
||||||
# Electron userData liegt automatisch in ~/.var/app/tv.twitch4steamdeck.App/config/
|
# Electron userData liegt automatisch in ~/.var/app/tv.twitch4steamdeck.App/config/
|
||||||
# Kein extra --filesystem nötig.
|
# Kein extra --filesystem nötig.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AuthService } from './auth/authService'
|
|||||||
import { HelixClient } from './twitch/helixClient'
|
import { HelixClient } from './twitch/helixClient'
|
||||||
import { PlaybackService } from './playback/playbackService'
|
import { PlaybackService } from './playback/playbackService'
|
||||||
import { registerIpcHandlers } from './ipc/handlers'
|
import { registerIpcHandlers } from './ipc/handlers'
|
||||||
|
import { startGamepadReader } from './input/gamepadReader'
|
||||||
|
|
||||||
const isDev = !app.isPackaged
|
const isDev = !app.isPackaged
|
||||||
|
|
||||||
@@ -49,7 +50,10 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
registerIpcHandlers(auth, helix, playback)
|
registerIpcHandlers(auth, helix, playback)
|
||||||
|
|
||||||
|
const stopGamepad = startGamepadReader(() => BrowserWindow.getAllWindows()[0] ?? null)
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
|
stopGamepad()
|
||||||
playback.stopAll()
|
playback.stopAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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<number, string> = {
|
||||||
|
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<typeof setTimeout> | null
|
||||||
|
interval: ReturnType<typeof setInterval> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
class JoystickReader {
|
||||||
|
private stream: fs.ReadStream | null = null
|
||||||
|
private buf = Buffer.alloc(0)
|
||||||
|
private axes = new Map<number, AxisState>()
|
||||||
|
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<string, JoystickReader>()
|
||||||
|
|
||||||
|
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<string>()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,11 @@ export function registerIpcHandlers(
|
|||||||
playback.on('playback-event', (event: PlaybackEvent) => {
|
playback.on('playback-event', (event: PlaybackEvent) => {
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
win.webContents.send(IPC.playbackEvent, event)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -17,7 +17,9 @@ const IPC = {
|
|||||||
playbackStartLive: 'playback:start-live',
|
playbackStartLive: 'playback:start-live',
|
||||||
playbackStartVod: 'playback:start-vod',
|
playbackStartVod: 'playback:start-vod',
|
||||||
playbackStop: 'playback:stop',
|
playbackStop: 'playback:stop',
|
||||||
playbackEvent: 'playback:event'
|
playbackEvent: 'playback:event',
|
||||||
|
|
||||||
|
gamepadInput: 'gamepad-input'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type AuthStatus = 'logged-out' | 'logged-in'
|
export type AuthStatus = 'logged-out' | 'logged-in'
|
||||||
@@ -117,6 +119,13 @@ const api = {
|
|||||||
ipcRenderer.on(IPC.playbackEvent, listener)
|
ipcRenderer.on(IPC.playbackEvent, listener)
|
||||||
return () => ipcRenderer.removeListener(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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,20 @@ export default function App(): JSX.Element {
|
|||||||
return off
|
return off
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
|
// Browser Gamepad API (funktioniert im Desktop-Modus / Windows)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
gamepadService.start()
|
gamepadService.start()
|
||||||
return () => gamepadService.stop()
|
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') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="fullscreen-center">
|
<div className="fullscreen-center">
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* dispatcht synthetische KeyboardEvents, sodass die Spatial-Navigation
|
* dispatcht synthetische KeyboardEvents, sodass die Spatial-Navigation
|
||||||
* ohne Gamepad-spezifischen Code in den Komponenten auskommt.
|
* ohne Gamepad-spezifischen Code in den Komponenten auskommt.
|
||||||
*
|
*
|
||||||
|
* Unterstützt mehrere Gamepads gleichzeitig (z.B. Steam Deck + Xbox BT).
|
||||||
|
*
|
||||||
* Standard-Mapping (Xbox / Steam Deck):
|
* Standard-Mapping (Xbox / Steam Deck):
|
||||||
* Button 12 = DPad Up → ArrowUp
|
* Button 12 = DPad Up → ArrowUp
|
||||||
* Button 13 = DPad Down → ArrowDown
|
* Button 13 = DPad Down → ArrowDown
|
||||||
@@ -10,15 +12,12 @@
|
|||||||
* Button 15 = DPad Right → ArrowRight
|
* Button 15 = DPad Right → ArrowRight
|
||||||
* Button 0 = A → Enter
|
* Button 0 = A → Enter
|
||||||
* Button 1 = B → Escape
|
* Button 1 = B → Escape
|
||||||
|
* Button 2 = X → x
|
||||||
|
* Button 3 = Y → y
|
||||||
* Axis 1 (left stick Y) → Up/Down
|
* Axis 1 (left stick Y) → Up/Down
|
||||||
* Axis 0 (left stick X) → Left/Right
|
* Axis 0 (left stick X) → Left/Right
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface ButtonState {
|
|
||||||
pressed: boolean
|
|
||||||
lastPressed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const BUTTON_MAP: Record<number, string> = {
|
const BUTTON_MAP: Record<number, string> = {
|
||||||
0: 'Enter', // A
|
0: 'Enter', // A
|
||||||
1: 'Escape', // B
|
1: 'Escape', // B
|
||||||
@@ -40,16 +39,19 @@ interface AxisRepeat {
|
|||||||
lastRepeat: number
|
lastRepeat: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PadState {
|
||||||
|
buttons: Map<number, boolean>
|
||||||
|
axes: [AxisRepeat, AxisRepeat]
|
||||||
|
}
|
||||||
|
|
||||||
export class GamepadService {
|
export class GamepadService {
|
||||||
private rafId: number | null = null
|
private rafId: number | null = null
|
||||||
private buttonStates = new Map<number, ButtonState>()
|
private padStates = new Map<number, PadState>()
|
||||||
private axisRepeat: [AxisRepeat, AxisRepeat] = [
|
|
||||||
{ key: null, heldSince: 0, lastRepeat: 0 },
|
|
||||||
{ key: null, heldSince: 0, lastRepeat: 0 }
|
|
||||||
]
|
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.rafId !== null) return
|
if (this.rafId !== null) return
|
||||||
|
window.addEventListener('gamepadconnected', this.onConnected)
|
||||||
|
window.addEventListener('gamepaddisconnected', this.onDisconnected)
|
||||||
this.poll()
|
this.poll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,29 +60,62 @@ export class GamepadService {
|
|||||||
cancelAnimationFrame(this.rafId)
|
cancelAnimationFrame(this.rafId)
|
||||||
this.rafId = null
|
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 => {
|
private poll = (): void => {
|
||||||
this.rafId = requestAnimationFrame(this.poll)
|
this.rafId = requestAnimationFrame(this.poll)
|
||||||
const gamepads = navigator.getGamepads()
|
const gamepads = navigator.getGamepads()
|
||||||
const gp = gamepads[0] // erstes angeschlossenes Gamepad
|
|
||||||
if (!gp) return
|
|
||||||
|
|
||||||
const now = performance.now()
|
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
|
// Buttons
|
||||||
for (const [index, key] of Object.entries(BUTTON_MAP)) {
|
for (const [index, key] of Object.entries(BUTTON_MAP)) {
|
||||||
const i = Number(index)
|
const i = Number(index)
|
||||||
const btn = gp.buttons[i]
|
const btn = gp.buttons[i]
|
||||||
if (!btn) continue
|
if (!btn) continue
|
||||||
const prev = this.buttonStates.get(i) ?? { pressed: false, lastPressed: false }
|
const wasPressed = state.buttons.get(i) ?? false
|
||||||
if (btn.pressed && !prev.pressed) {
|
if (btn.pressed && !wasPressed) {
|
||||||
this.dispatch(key)
|
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][] = [
|
const axisDefs: [number, string, string][] = [
|
||||||
[0, 'ArrowLeft', 'ArrowRight'],
|
[0, 'ArrowLeft', 'ArrowRight'],
|
||||||
[1, 'ArrowUp', 'ArrowDown']
|
[1, 'ArrowUp', 'ArrowDown']
|
||||||
@@ -88,24 +123,24 @@ export class GamepadService {
|
|||||||
for (let a = 0; a < 2; a++) {
|
for (let a = 0; a < 2; a++) {
|
||||||
const [axisIdx, negKey, posKey] = axisDefs[a]
|
const [axisIdx, negKey, posKey] = axisDefs[a]
|
||||||
const val = gp.axes[axisIdx] ?? 0
|
const val = gp.axes[axisIdx] ?? 0
|
||||||
const state = this.axisRepeat[a]
|
const ar = state.axes[a]
|
||||||
|
|
||||||
let activeKey: string | null = null
|
let activeKey: string | null = null
|
||||||
if (val < -AXIS_THRESHOLD) activeKey = negKey
|
if (val < -AXIS_THRESHOLD) activeKey = negKey
|
||||||
else if (val > AXIS_THRESHOLD) activeKey = posKey
|
else if (val > AXIS_THRESHOLD) activeKey = posKey
|
||||||
|
|
||||||
if (activeKey !== state.key) {
|
if (activeKey !== ar.key) {
|
||||||
state.key = activeKey
|
ar.key = activeKey
|
||||||
state.heldSince = now
|
ar.heldSince = now
|
||||||
state.lastRepeat = 0
|
ar.lastRepeat = 0
|
||||||
if (activeKey) this.dispatch(activeKey)
|
if (activeKey) this.dispatch(activeKey)
|
||||||
} else if (activeKey) {
|
} else if (activeKey) {
|
||||||
const held = now - state.heldSince
|
const held = now - ar.heldSince
|
||||||
if (held > REPEAT_INITIAL_MS) {
|
if (held > REPEAT_INITIAL_MS) {
|
||||||
if (state.lastRepeat === 0) state.lastRepeat = now
|
if (ar.lastRepeat === 0) ar.lastRepeat = now
|
||||||
if (now - state.lastRepeat > REPEAT_INTERVAL_MS) {
|
if (now - ar.lastRepeat > REPEAT_INTERVAL_MS) {
|
||||||
this.dispatch(activeKey)
|
this.dispatch(activeKey)
|
||||||
state.lastRepeat = now
|
ar.lastRepeat = now
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export default function BrowseScreen({
|
|||||||
const [gridIndex, setGridIndex] = useState(0)
|
const [gridIndex, setGridIndex] = useState(0)
|
||||||
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
|
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
const gridRef = useRef<HTMLDivElement>(null)
|
const gridRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -80,6 +81,13 @@ export default function BrowseScreen({
|
|||||||
void load()
|
void load()
|
||||||
}, [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
|
// Nächste Seite automatisch nachladen wenn letztes Element fokussiert wird
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusRegion !== 'grid' || loadState !== 'ok') return
|
if (focusRegion !== 'grid' || loadState !== 'ok') return
|
||||||
@@ -137,6 +145,12 @@ export default function BrowseScreen({
|
|||||||
if (ch) onSelectChannel(ch)
|
if (ch) onSelectChannel(ch)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'Escape': {
|
||||||
|
e.preventDefault()
|
||||||
|
if (isPlaying) void window.t4sd.playback.stop()
|
||||||
|
else onRequestSidebar()
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// grid region
|
// grid region
|
||||||
@@ -170,13 +184,19 @@ export default function BrowseScreen({
|
|||||||
if (game) onSelectCategory(game)
|
if (game) onSelectCategory(game)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'Escape': {
|
||||||
|
e.preventDefault()
|
||||||
|
if (isPlaying) void window.t4sd.playback.stop()
|
||||||
|
else onRequestSidebar()
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('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
|
// Grid-Karte in den Viewport scrollen wenn fokussiert
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setFocusRegion('shelf')
|
setFocusRegion('shelf')
|
||||||
setShelfIndex(0)
|
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') {
|
} else if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (playState === 'playing' || playState === 'starting') {
|
if (playState === 'playing' || playState === 'starting') {
|
||||||
|
|||||||
@@ -90,12 +90,22 @@ export default function FollowingScreen({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setFocusedIndex((i) => Math.max(i - cols, 0))
|
setFocusedIndex((i) => Math.max(i - cols, 0))
|
||||||
break
|
break
|
||||||
|
case 'Enter': {
|
||||||
|
e.preventDefault()
|
||||||
|
const ch = channels[focusedIndex]
|
||||||
|
if (ch) onSelectChannel(ch)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
onRequestSidebar()
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('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
|
const liveCount = channels.filter((c) => c.isLive).length
|
||||||
|
|
||||||
|
|||||||
Vendored
+3
@@ -83,6 +83,9 @@ export interface T4sdApi {
|
|||||||
stop: () => Promise<void>
|
stop: () => Promise<void>
|
||||||
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
||||||
}
|
}
|
||||||
|
gamepad: {
|
||||||
|
onInput: (cb: (key: string) => void) => () => void
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
Reference in New Issue
Block a user