cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

main
goq / portal / controller-runtime.mjs
controller-runtime.mjs 3.4K
1import {2 ControllerActionRepeater,3 GamepadEscapeHold,4 LatestControllerStatePublisher,5 controllerStateSignature,6 disconnectedControllerState,7 maskGamepadEscapeChord,8 normalizeGamepad,9 selectPreferredController,10 toGamepadInputState,11} from './controller-state.mjs';1213export function createControllerRuntime({14 getGamepads = () => globalThis.navigator?.getGamepads?.() ?? [],15 schedulePoll = (callback) => requestAnimationFrame(callback),16 isRemoteRoute = () => false,17 sendRemoteState = () => {},18 onNavigate = () => {},19 onActivate = () => {},20 onBack = () => {},21 onStatus = () => {},22 onExit = () => {},23 warn = (...args) => console.warn(...args),24 publisher = new LatestControllerStatePublisher(),25 actions = new ControllerActionRepeater(),26 escape = new GamepadEscapeHold(1000),27} = {}) {28 let sequence = 0;29 let selectedIndex = null;30 let currentState = disconnectedControllerState();31 let lastSignature = controllerStateSignature(currentState);32 let observer = null;3334 publisher.setHandler((state) => {35 if (observer) {36 try {37 observer(state);38 } catch (error) {39 warn('controller observer failed:', error);40 }41 }42 // Route state whenever the remote control path is active. Disconnected and43 // non-standard samples intentionally map to neutral so controller loss44 // cannot leave held input on the host.45 if (!isRemoteRoute()) return;46 sendRemoteState(maskGamepadEscapeChord(toGamepadInputState(state)));47 });4849 function publishCurrentState() {50 publisher.publish(currentState);51 }5253 function poll(nowMs) {54 let gamepads = [];55 try {56 gamepads = Array.from(getGamepads() ?? []).filter(Boolean);57 } catch (error) {58 warn('gamepad poll failed:', error);59 }60 const gamepad = selectPreferredController(gamepads, selectedIndex);61 const nextIndex = gamepad?.index ?? null;62 if (nextIndex !== selectedIndex) {63 selectedIndex = nextIndex;64 actions.reset();65 escape.reset();66 lastSignature = '';67 }6869 currentState = normalizeGamepad(gamepad, sequence + 1);70 const signature = controllerStateSignature(currentState);71 if (signature !== lastSignature) {72 sequence += 1;73 currentState = normalizeGamepad(gamepad, sequence);74 lastSignature = signature;75 publisher.publish(currentState);76 onStatus(currentState);77 }7879 const remoteRoute = isRemoteRoute()80 && currentState.connected81 && currentState.mapping === 'standard';82 if (remoteRoute) {83 if (escape.update(currentState, nowMs)) {84 onExit();85 actions.reset();86 }87 } else {88 escape.reset();89 for (const action of actions.update(currentState, nowMs)) {90 if (action.type === 'navigate') onNavigate(action.direction);91 else if (action.type === 'activate') onActivate();92 else if (action.type === 'back') onBack();93 }94 }95 schedulePoll(poll);96 }9798 function start() {99 schedulePoll(poll);100 }101102 function setObserver(nextObserver) {103 if (nextObserver !== null && typeof nextObserver !== 'function') {104 throw new TypeError('observer must be a function or null');105 }106 observer = nextObserver;107 }108109 return Object.freeze({110 poll,111 publishCurrentState,112 resetEscape: () => escape.reset(),113 setObserver,114 start,115 get latest() {116 return publisher.latest;117 },118 get current() {119 return currentState;120 },121 });122}