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:
@@ -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 (
|
||||
<div className="fullscreen-center">
|
||||
|
||||
@@ -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<number, string> = {
|
||||
0: 'Enter', // A
|
||||
1: 'Escape', // B
|
||||
@@ -40,16 +39,19 @@ interface AxisRepeat {
|
||||
lastRepeat: number
|
||||
}
|
||||
|
||||
interface PadState {
|
||||
buttons: Map<number, boolean>
|
||||
axes: [AxisRepeat, AxisRepeat]
|
||||
}
|
||||
|
||||
export class GamepadService {
|
||||
private rafId: number | null = null
|
||||
private buttonStates = new Map<number, ButtonState>()
|
||||
private axisRepeat: [AxisRepeat, AxisRepeat] = [
|
||||
{ key: null, heldSince: 0, lastRepeat: 0 },
|
||||
{ key: null, heldSince: 0, lastRepeat: 0 }
|
||||
]
|
||||
private padStates = new Map<number, PadState>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function BrowseScreen({
|
||||
const [gridIndex, setGridIndex] = useState(0)
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const gridRef = useRef<HTMLDivElement>(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(() => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Vendored
+3
@@ -83,6 +83,9 @@ export interface T4sdApi {
|
||||
stop: () => Promise<void>
|
||||
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
||||
}
|
||||
gamepad: {
|
||||
onInput: (cb: (key: string) => void) => () => void
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
Reference in New Issue
Block a user