From 65998205425a9c1cadd1408651332809efe2f506 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Fri, 26 Jan 2024 08:29:06 +0100 Subject: [PATCH] feat: add support for keyboard shortcuts, xkeys, streamdeck and spacemouse --- packages/apps/client/package.json | 7 +- .../ScriptEditor/plugins/updateModel.ts | 2 +- .../SystemStatusAlertBars.tsx | 28 ++ .../client/src/hooks/useControllerMessages.ts | 4 +- .../triggerActions/TriggerActionHandler.ts | 64 +++++ .../src/lib/triggerActions/triggerActions.ts | 36 +++ .../client/src/lib/triggers/triggerConfig.ts | 102 +++++++ .../triggerHandlers/TriggerHandler.ts | 16 ++ .../triggerHandlers/TriggerHandlerKeyboard.ts | 43 +++ .../TriggerHandlerSpaceMouse.ts | 188 +++++++++++++ .../TriggerHandlerStreamdeck.ts | 198 ++++++++++++++ .../triggerHandlers/TriggerHandlerXKeys.ts | 252 ++++++++++++++++++ .../apps/client/src/lib/triggers/triggers.ts | 114 ++++++++ .../apps/client/src/stores/RootAppStore.ts | 10 +- .../apps/client/src/stores/TriggerStore.ts | 77 ++++++ yarn.lock | 99 ++++++- 16 files changed, 1231 insertions(+), 9 deletions(-) create mode 100644 packages/apps/client/src/lib/triggerActions/TriggerActionHandler.ts create mode 100644 packages/apps/client/src/lib/triggerActions/triggerActions.ts create mode 100644 packages/apps/client/src/lib/triggers/triggerConfig.ts create mode 100644 packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandler.ts create mode 100644 packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerKeyboard.ts create mode 100644 packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerSpaceMouse.ts create mode 100644 packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerStreamdeck.ts create mode 100644 packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerXKeys.ts create mode 100644 packages/apps/client/src/lib/triggers/triggers.ts create mode 100644 packages/apps/client/src/stores/TriggerStore.ts diff --git a/packages/apps/client/package.json b/packages/apps/client/package.json index 321284b..4a5cab3 100644 --- a/packages/apps/client/package.json +++ b/packages/apps/client/package.json @@ -34,11 +34,14 @@ "vite": "^4.5.0" }, "dependencies": { + "@elgato-stream-deck/webhid": "^6.0.0", "@popperjs/core": "^2.11.8", "@react-hook/resize-observer": "^1.2.6", + "@sofie-automation/sorensen": "^1.4.2", "@sofie-prompter-editor/shared-lib": "0.0.0", "@sofie-prompter-editor/shared-model": "0.0.0", "bootstrap": "^5.3.2", + "buffer": "^6.0.3", "eventemitter3": "^5.0.1", "mobx": "^6.10.2", "mobx-react-lite": "^4.0.5", @@ -56,7 +59,9 @@ "react-helmet-async": "^1.3.0", "react-router-dom": "^6.18.0", "socket.io-client": "^4.7.2", - "uuid": "^9.0.1" + "spacemouse-webhid": "^0.0.2", + "uuid": "^9.0.1", + "xkeys-webhid": "^3.1.0" }, "lint-staged": { "*.{js,css,json,md,scss}": [ diff --git a/packages/apps/client/src/components/ScriptEditor/plugins/updateModel.ts b/packages/apps/client/src/components/ScriptEditor/plugins/updateModel.ts index ebe828f..21987be 100644 --- a/packages/apps/client/src/components/ScriptEditor/plugins/updateModel.ts +++ b/packages/apps/client/src/components/ScriptEditor/plugins/updateModel.ts @@ -1,5 +1,5 @@ import { Plugin } from 'prosemirror-state' -import { UILineId } from '../../model/UILine' +import { UILineId } from '../../../model/UILine' import { Node } from 'prosemirror-model' import { AddMarkStep, RemoveMarkStep, StepMap } from 'prosemirror-transform' import { schema } from '../scriptSchema' diff --git a/packages/apps/client/src/components/SystemStatusAlertBars/SystemStatusAlertBars.tsx b/packages/apps/client/src/components/SystemStatusAlertBars/SystemStatusAlertBars.tsx index 39cd598..f500c7d 100644 --- a/packages/apps/client/src/components/SystemStatusAlertBars/SystemStatusAlertBars.tsx +++ b/packages/apps/client/src/components/SystemStatusAlertBars/SystemStatusAlertBars.tsx @@ -2,14 +2,42 @@ import { observer } from 'mobx-react-lite' import React from 'react' import { RootAppStore } from 'src/stores/RootAppStore' import { AlertBar } from 'src/components/AlertBar/AlertBar' +import { Button } from 'react-bootstrap' export const SystemStatusAlertBars = observer(function SystemStatusAlertBars(): React.JSX.Element { const isAPIConnected = RootAppStore.connected const isSofieConnected = RootAppStore.sofieConnected + + const hidAccessRequest = Array.from(RootAppStore.triggerStore.hidDeviceAccessRequests.values())[0] + + let requestAccess: { + name: string + allow: () => void + deny: () => void + } | null = null + if (hidAccessRequest) { + requestAccess = { + name: hidAccessRequest.deviceName, + allow: () => hidAccessRequest.callback(true), + deny: () => hidAccessRequest.callback(false), + } + } + return ( <> {!isAPIConnected ? Prompter is having network troubles : null} {!isSofieConnected ? Prompter is having trouble connecting to Sofie : null} + {requestAccess ? ( + + Please allow access to {requestAccess.name} to setup shortcuts: + + + + ) : null} ) }) diff --git a/packages/apps/client/src/hooks/useControllerMessages.ts b/packages/apps/client/src/hooks/useControllerMessages.ts index 47bcbf0..bd36102 100644 --- a/packages/apps/client/src/hooks/useControllerMessages.ts +++ b/packages/apps/client/src/hooks/useControllerMessages.ts @@ -9,7 +9,7 @@ export function useControllerMessages(ref: React.RefObject, heightP useEffect(() => { const onMessage = (message: { speed: number }) => { - console.log('received message', message) + // console.log('received message', message) speed.current = message.speed } @@ -21,7 +21,7 @@ export function useControllerMessages(ref: React.RefObject, heightP const frameTime = lastFrameTime.current === null ? 16 : now - lastFrameTime.current const scrollBy = ((speed.current * fontSizePx) / 300) * frameTime position.current = Math.max(0, position.current + scrollBy) - console.log(position.current) + // console.log(position.current) ref.current?.scrollTo(0, position.current) diff --git a/packages/apps/client/src/lib/triggerActions/TriggerActionHandler.ts b/packages/apps/client/src/lib/triggerActions/TriggerActionHandler.ts new file mode 100644 index 0000000..5981183 --- /dev/null +++ b/packages/apps/client/src/lib/triggerActions/TriggerActionHandler.ts @@ -0,0 +1,64 @@ +import { assertNever } from '@sofie-prompter-editor/shared-lib' +import { RootAppStore } from '../../stores/RootAppStore.ts' +import { AnyTriggerAction } from './triggerActions.ts' + +/** + * The TriggerActionHandler is responsible for listening to some action events and executing the actions accordingly. + */ +export class TriggerActionHandler { + private prompterSpeed = 0 + + constructor(private store: typeof RootAppStore) { + this.onAction = this.onAction.bind(this) + + this.store.triggerStore.on('action', this.onAction) + } + + private onAction(action: AnyTriggerAction) { + console.log('action', JSON.stringify(action)) + + if (action.type === 'prompterMove') { + this.prompterSpeed = action.payload.speed + this.sendPrompterSpeed() + } else if (action.type === 'prompterAccelerate') { + this.prompterSpeed += action.payload.accelerate + this.sendPrompterSpeed() + } else if (action.type === 'prompterJump') { + // TODO + // this.store.connection.controller.sendMessage({ + // speed: 0, + // offset: action.payload.offset, + // }) + } else if (action.type === 'movePrompterToHere') { + // Not handled here + } else { + assertNever(action) + } + } + + private sendPrompterSpeed() { + // Send message with speed: + + // Modify the value so that + const speed = this.attackCurve(this.prompterSpeed, 10, 0.7) + + this.store.connection.controller.sendMessage({ + speed: speed, + offset: null, + }) + } + destroy(): void {} + + /** + * Scales a value which follows a curve which at low values (0-normalValue) is pretty much linear, + * but at higher values (normalValue+) grows much faster + * @param x The value to scale + * @param power The power of the curve + * @param normalValue The value up to which at which the curve is mostly linear + */ + private attackCurve(x: number, power = 1, normalValue = 1): number { + const attack = Math.sign(x) * Math.abs(Math.pow(x, power) * Math.pow(1 / normalValue, power)) + const linear = x + return linear + attack + } +} diff --git a/packages/apps/client/src/lib/triggerActions/triggerActions.ts b/packages/apps/client/src/lib/triggerActions/triggerActions.ts new file mode 100644 index 0000000..8c18fc6 --- /dev/null +++ b/packages/apps/client/src/lib/triggerActions/triggerActions.ts @@ -0,0 +1,36 @@ +/* eslint-disable no-mixed-spaces-and-tabs */ + +import { ControllerMessage } from '@sofie-prompter-editor/shared-model' + +export type AnyTriggerAction = + | TriggerAction< + 'prompterMove', + { + /** The speed to move the prompter */ + speed: number + } + > + | TriggerAction< + 'prompterAccelerate', + { + /** The acceleration move the prompter */ + accelerate: number + } + > + | TriggerAction< + 'prompterJump', + { + offset: ControllerMessage['offset'] + } + > + | TriggerAction< + 'movePrompterToHere', + { + // nothing + } + > + +type TriggerAction> = { + type: Type + payload: Payload +} diff --git a/packages/apps/client/src/lib/triggers/triggerConfig.ts b/packages/apps/client/src/lib/triggers/triggerConfig.ts new file mode 100644 index 0000000..6b1ed05 --- /dev/null +++ b/packages/apps/client/src/lib/triggers/triggerConfig.ts @@ -0,0 +1,102 @@ +import { BindOptions } from '@sofie-automation/sorensen' +import { AnyTriggerAction } from '../triggerActions/triggerActions.ts' +import { DeviceModelId } from '@elgato-stream-deck/webhid' + +export type TriggerConfig = + | TriggerConfigKeyboard + | TriggerConfigXkeys + | TriggerConfigStreamdeck + | TriggerConfigSpacemouse + +export enum TriggerConfigType { + KEYBOARD = 'keyboard', + XKEYS = 'xkeys', + STREAMDECK = 'streamdeck', + SPACEMOUSE = 'spacemouse', +} +export interface TriggerConfigBase { + type: TriggerConfigType +} +export interface TriggerConfigKeyboard extends TriggerConfigBase { + type: TriggerConfigType.KEYBOARD + + action: AnyTriggerAction + /** + * a "+" and space concatenated list of KeyboardEvent.key key values (see: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values), + * in order (order not significant for modifier keys), f.g. "Control+Shift+KeyA", "Control+Shift+KeyB KeyU". + * "Control" means "either ControlLeft or ControlRight", same for "Shift" and "Alt" + * Spaces indicate chord sequences. + */ + keys: string + + /** + * If enabled, actions will happen on keyUp, as opposed to keyDown + * + * @type {boolean} + * @memberof IBlueprintHotkeyTrigger + */ + up?: boolean + + global?: BindOptions['global'] +} +export interface TriggerConfigXkeys extends TriggerConfigBase { + type: TriggerConfigType.XKEYS + + /** userId of the xkeys panel, or null to match any */ + productId: number | null + /** userId of the xkeys, or null to match any */ + unitId: number | null + + eventType: 'down' | 'up' | 'jog' | 'joystick' | 'rotary' | 'shuttle' | 'tbar' | 'trackball' + /** Index of the key, joystick, etc */ + index: number + + /** If action.payload is not set, use value from the xkeys */ + action: + | AnyTriggerAction + | { + type: AnyTriggerAction['type'] + // no payload, use value from the xkeys + } +} +export interface TriggerConfigStreamdeck extends TriggerConfigBase { + type: TriggerConfigType.STREAMDECK + + /** userId of the Streamdeck, or null to match any */ + modelId: DeviceModelId | null + /** userId of the Streamdeck, or null to match any */ + serialNumber: string | null + + eventType: 'down' | 'up' | 'rotate' | 'encoderDown' | 'encoderUp' + /** Index of the key, knob, etc */ + index: number + + /** If action.payload is not set, use value from the xkeys */ + action: + | AnyTriggerAction + | { + type: AnyTriggerAction['type'] + // no payload, use value from the streamdeck + } +} + +export interface TriggerConfigSpacemouse extends TriggerConfigBase { + type: TriggerConfigType.SPACEMOUSE + + /** userId of the xkeys panel, or null to match any */ + productId: number | null + /** userId of the xkeys, or null to match any */ + unitId: number | null + + eventType: 'down' | 'up' | 'rotate' | 'translate' + /** Index of the key, if needed, 0 otherwise */ + index: number + + /** If action.payload is not set, use value from the xkeys */ + action: + | AnyTriggerAction + | { + type: AnyTriggerAction['type'] + // no payload, use value from the xkeys + } +} diff --git a/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandler.ts b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandler.ts new file mode 100644 index 0000000..54c0ed8 --- /dev/null +++ b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandler.ts @@ -0,0 +1,16 @@ +import { EventEmitter } from 'eventemitter3' +import { AnyTriggerAction } from '../../triggerActions/triggerActions.ts' +import { TriggerConfig } from '../triggerConfig.ts' + +export interface TriggerHandlerEvents { + action: [action: AnyTriggerAction] + + /** A message indicating that we want to ask the user if we should request access to the HIDDevice */ + requestHIDDeviceAccess: [deviceName: string, callback: (access: boolean) => void] +} + +export abstract class TriggerHandler extends EventEmitter { + protected triggers: TriggerConfig[] = [] + abstract initialize(triggers?: TriggerConfig[]): Promise + abstract destroy(): Promise +} diff --git a/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerKeyboard.ts b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerKeyboard.ts new file mode 100644 index 0000000..bc01552 --- /dev/null +++ b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerKeyboard.ts @@ -0,0 +1,43 @@ +import Sorensen from '@sofie-automation/sorensen' +import { TriggerHandler } from './TriggerHandler' +import { TriggerConfig, TriggerConfigType } from '../triggerConfig' + +export class TriggerHandlerKeyboard extends TriggerHandler { + async initialize(triggers?: TriggerConfig[]): Promise { + if (triggers) this.triggers = triggers + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const theWindow = window as any + // hot-module-reload fix: + if (!theWindow.sorensenInitialized) { + theWindow.sorensenInitialized = true + await Sorensen.init() + } else { + await Sorensen.destroy() + await Sorensen.init() + } + + for (const trigger of this.triggers) { + if (trigger.type !== TriggerConfigType.KEYBOARD) continue + + Sorensen.bind( + trigger.keys, + () => { + this.emit('action', trigger.action) + }, + { + up: trigger.up, + global: trigger.global, + + exclusive: true, + ordered: 'modifiersFirst', + preventDefaultPartials: false, + preventDefaultDown: true, + } + ) + } + } + async destroy(): Promise { + Sorensen.destroy().catch((e: Error) => console.error('Sorensen.destroy', e)) + } +} diff --git a/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerSpaceMouse.ts b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerSpaceMouse.ts new file mode 100644 index 0000000..5efb2c1 --- /dev/null +++ b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerSpaceMouse.ts @@ -0,0 +1,188 @@ +import { assertNever } from '@sofie-prompter-editor/shared-lib' +import { getOpenedSpaceMice, requestSpaceMice, setupSpaceMouse, SpaceMouse, VENDOR_IDS } from 'spacemouse-webhid' +import { TriggerHandler } from './TriggerHandler' +import { TriggerConfig, TriggerConfigType, TriggerConfigSpacemouse } from '../triggerConfig' +import { AnyTriggerAction } from '../../triggerActions/triggerActions' + +export class TriggerHandlerSpaceMouse extends TriggerHandler { + private neededPanelIds = new Set<{ + productId: number | null + }>() + + private connectedPanels: SpaceMouse[] = [] + + private triggerKeys: TriggerConfigSpacemouse[] = [] + private triggerXYZ: TriggerConfigSpacemouse[] = [] + + async initialize(triggers?: TriggerConfig[]): Promise { + if (triggers) this.triggers = triggers + // Make list of which panels we have triggers for: + for (const trigger of this.triggers) { + if (trigger.type !== TriggerConfigType.SPACEMOUSE) continue + this.neededPanelIds.add({ productId: trigger.productId }) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const theWindow = window as any + + // hot-module-reload fix: + if (!theWindow.spacemouseInitialized) { + theWindow.spacemouseInitialized = true + theWindow.spacemouseConnectedPanels = this.connectedPanels + + // Get list of already opened panels and connect to them: + const alreadyOpenedDevices = await getOpenedSpaceMice() + for (const device of alreadyOpenedDevices) { + if (!VENDOR_IDS.includes(device.vendorId)) continue + + await this.connectToHIDDevice(device) + } + + if (this.neededPanelIds.size > 0) { + // We have triggers setup for panels we don't have access to. + // Emit an event which will prompt the user to grant access: + this.emit('requestHIDDeviceAccess', 'SpaceMouse / SpaceNavigator', (allow) => { + this.allowAccess(allow) + }) + } + } else { + this.connectedPanels = theWindow.spacemouseConnectedPanels + } + + this.triggerKeys = [] + this.triggerXYZ = [] + for (const trigger of this.triggers) { + if (trigger.type !== TriggerConfigType.SPACEMOUSE) continue + + if (trigger.eventType === 'down' || trigger.eventType === 'up') { + this.triggerKeys.push(trigger) + } else if (trigger.eventType === 'rotate' || trigger.eventType === 'translate') { + this.triggerXYZ.push(trigger) + } else { + assertNever(trigger.eventType) + } + } + + for (const panel of this.connectedPanels) { + panel.removeAllListeners('error') + panel.removeAllListeners('down') + panel.removeAllListeners('up') + panel.removeAllListeners('rotate') + panel.removeAllListeners('translate') + + panel.on('error', (e) => { + console.error('spacemouse error', e) + }) + panel.on('down', (keyIndex: number) => { + const action = this.getKeyAction('down', keyIndex) + if (action) this.emit('action', action) + }) + panel.on('up', (keyIndex: number) => { + const action = this.getKeyAction('up', keyIndex) + if (action) this.emit('action', action) + }) + + panel.on('rotate', (value) => { + const xyz = { + x: value.pitch, + y: value.roll, + z: value.yaw, + } + const action = this.getXYZAction('rotate', xyz) + if (action) this.emit('action', action) + }) + panel.on('translate', (zyz) => { + const action = this.getXYZAction('translate', zyz) + if (action) this.emit('action', action) + }) + } + } + allowAccess(allow: boolean) { + if (allow) { + this.requestSpacemousePanels().catch(console.error) + } + } + private async requestSpacemousePanels() { + // Must be handling a user gesture to show a permission request + + // Connect to a new panel: + const newDevices = await requestSpaceMice() + for (const device of newDevices) { + await this.connectToHIDDevice(device) + } + await this.initialize() + } + async destroy(): Promise { + await Promise.all(this.connectedPanels.map((panel) => panel.close())) + } + + private async connectToHIDDevice(device: HIDDevice) { + const panel = await setupSpaceMouse(device) + + const matches = this.matchNeededPanel(panel.info.productId) + for (const match of matches) { + this.neededPanelIds.delete(match) + } + if (matches.length > 0) { + this.connectedPanels.push(panel) + } else { + await panel.close() + } + } + + private matchNeededPanel(productId: number) { + const matched: { + productId: number | null + }[] = [] + for (const needed of this.neededPanelIds.values()) { + if (needed.productId === null || needed.productId === productId) { + matched.push(needed) + } + } + return matched + } + /** Generate an action from a key input */ + private getKeyAction(eventType: string, keyIndex: number): AnyTriggerAction | undefined { + const trigger: TriggerConfigSpacemouse | undefined = this.triggerKeys.find( + (t) => t.eventType === eventType && t.index === keyIndex + ) + if (!trigger) return undefined + + if ('payload' in trigger.action) return trigger.action // Already defined, just pass through + + if (trigger.action.type === 'prompterMove') { + // ignore + } else if (trigger.action.type === 'prompterAccelerate') { + // ignore + } else if (trigger.action.type === 'prompterJump') { + // ignore + } else if (trigger.action.type === 'movePrompterToHere') { + return { + type: 'movePrompterToHere', + payload: {}, + } + } else { + assertNever(trigger.action.type) + } + return undefined + } + /** Generate an action from a "XYZ type" input */ + private getXYZAction(eventType: string, xyz: { x: number; y: number; z: number }): AnyTriggerAction | undefined { + const trigger: TriggerConfigSpacemouse | undefined = this.triggerXYZ.find((t) => t.eventType === eventType) + if (!trigger) return undefined + if ('payload' in trigger.action) return trigger.action // Already defined, just pass through + + if (trigger.action.type === 'prompterMove') { + return { + type: 'prompterMove', + payload: { speed: xyz.x + xyz.y + xyz.z }, + } + } else if (trigger.action.type === 'prompterAccelerate') { + return { + type: 'prompterAccelerate', + payload: { accelerate: xyz.x + xyz.y + xyz.z }, + } + } + return undefined + } +} diff --git a/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerStreamdeck.ts b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerStreamdeck.ts new file mode 100644 index 0000000..6816c14 --- /dev/null +++ b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerStreamdeck.ts @@ -0,0 +1,198 @@ +import { assertNever } from '@sofie-prompter-editor/shared-lib' +import { getStreamDecks, requestStreamDecks, DeviceModelId, StreamDeckWeb } from '@elgato-stream-deck/webhid' +import { TriggerHandler } from './TriggerHandler' +import { TriggerConfig, TriggerConfigStreamdeck, TriggerConfigType } from '../triggerConfig' +import { AnyTriggerAction } from '../../triggerActions/triggerActions' +import { Buffer as WebBuffer } from 'buffer' +window.Buffer = WebBuffer // This is a polyfill to get the Streamdeck working in the browser + +export class TriggerHandlerStreamdeck extends TriggerHandler { + private neededPanelIds = new Set<{ + modelId: DeviceModelId | null + serialNumber: string | null + }>() + + private connectedPanels: StreamDeckWeb[] = [] + + private triggerKeys: TriggerConfigStreamdeck[] = [] + private triggerAnalog: TriggerConfigStreamdeck[] = [] + + async initialize(triggers?: TriggerConfig[]): Promise { + if (triggers) this.triggers = triggers + // Make list of which panels we have triggers for: + for (const trigger of this.triggers) { + if (trigger.type !== TriggerConfigType.STREAMDECK) continue + this.neededPanelIds.add({ modelId: trigger.modelId, serialNumber: trigger.serialNumber }) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const theWindow = window as any + + // hot-module-reload fix: + if (!theWindow.streamdeckInitialized) { + theWindow.streamdeckInitialized = true + theWindow.streamdeckConnectedPanels = this.connectedPanels + + // Get list of already opened panels and connect to them: + const alreadyOpenedPanel = await getStreamDecks() + for (const panel of alreadyOpenedPanel) { + await this.connectToHIDDevice(panel) + } + + if (this.neededPanelIds.size > 0) { + // We have triggers setup for panels we don't have access to. + // Emit an event which will prompt the user to grant access: + this.emit('requestHIDDeviceAccess', 'Streamdeck', (allow) => { + this.allowAccess(allow) + }) + } + } else { + this.connectedPanels = theWindow.streamdeckConnectedPanels + } + + this.triggerKeys = [] + this.triggerAnalog = [] + for (const trigger of this.triggers) { + if (trigger.type !== TriggerConfigType.STREAMDECK) continue + + if ( + trigger.eventType === 'down' || + trigger.eventType === 'up' || + trigger.eventType === 'encoderDown' || + trigger.eventType === 'encoderUp' + ) { + this.triggerKeys.push(trigger) + } else if (trigger.eventType === 'rotate') { + this.triggerAnalog.push(trigger) + } else { + assertNever(trigger.eventType) + } + } + + for (const panel of this.connectedPanels) { + panel.removeAllListeners('error') + panel.removeAllListeners('down') + panel.removeAllListeners('up') + panel.removeAllListeners('rotateLeft') + panel.removeAllListeners('rotateRight') + panel.removeAllListeners('encoderDown') + panel.removeAllListeners('encoderUp') + + panel.on('error', (e) => { + console.error('streamdeck error', e) + }) + panel.on('down', (keyIndex: number) => { + const action = this.getKeyAction('down', keyIndex) + if (action) this.emit('action', action) + }) + panel.on('up', (keyIndex: number) => { + const action = this.getKeyAction('up', keyIndex) + if (action) this.emit('action', action) + }) + panel.on('rotateLeft', (index: number, value) => { + const action = this.getAnalogAction('rotate', index, -value) + if (action) this.emit('action', action) + }) + panel.on('rotateRight', (index: number, value) => { + const action = this.getAnalogAction('rotate', index, value) + if (action) this.emit('action', action) + }) + panel.on('encoderDown', (index: number) => { + const action = this.getKeyAction('encoderDown', index) + if (action) this.emit('action', action) + }) + panel.on('encoderUp', (index: number) => { + const action = this.getKeyAction('encoderUp', index) + if (action) this.emit('action', action) + }) + } + } + allowAccess(allow: boolean) { + if (allow) { + this.requestStreamdeckPanels().catch(console.error) + } + } + private async requestStreamdeckPanels() { + // Must be handling a user gesture to show a permission request + + // Connect to a new panel: + const newDevices = await requestStreamDecks() + for (const device of newDevices) { + await this.connectToHIDDevice(device) + } + await this.initialize() + } + async destroy(): Promise { + await Promise.all(this.connectedPanels.map((panel) => panel.close())) + } + + private async connectToHIDDevice(panel: StreamDeckWeb) { + const serialNumber = await panel.getSerialNumber() + const matches = this.matchNeededPanel(panel.MODEL, serialNumber) + for (const match of matches) { + this.neededPanelIds.delete(match) + } + if (matches.length > 0) { + this.connectedPanels.push(panel) + } else { + await panel.close() + } + } + + private matchNeededPanel(modelId: DeviceModelId, serialNumber: string) { + const matched: { + modelId: DeviceModelId | null + serialNumber: string | null + }[] = [] + for (const needed of this.neededPanelIds.values()) { + if ( + (needed.modelId === null || needed.modelId === modelId) && + (needed.serialNumber === null || needed.serialNumber === serialNumber) + ) { + matched.push(needed) + } + } + return matched + } + /** Generate an action from a key input */ + private getKeyAction(eventType: string, keyIndex: number): AnyTriggerAction | undefined { + const trigger: TriggerConfigStreamdeck | undefined = this.triggerKeys.find( + (t) => t.eventType === eventType && t.index === keyIndex + ) + if (!trigger) return undefined + + if ('payload' in trigger.action) return trigger.action // Already defined, just pass through + + if (trigger.action.type === 'prompterMove') { + // ignore + } else if (trigger.action.type === 'prompterAccelerate') { + // ignore + } else if (trigger.action.type === 'prompterJump') { + // ignore + } else if (trigger.action.type === 'movePrompterToHere') { + return { + type: 'movePrompterToHere', + payload: {}, + } + } else { + assertNever(trigger.action.type) + } + return undefined + } + /** Generate an action from a "analog type" input */ + private getAnalogAction(eventType: string, index: number, value: number): AnyTriggerAction | undefined { + const trigger: TriggerConfigStreamdeck | undefined = this.triggerAnalog.find( + (t) => t.eventType === eventType && t.index === index + ) + if (!trigger) return undefined + + if ('payload' in trigger.action) return trigger.action // Already defined, just pass through + + if (trigger.action.type === 'prompterMove') { + return { + type: 'prompterMove', + payload: { speed: value }, + } + } + return undefined + } +} diff --git a/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerXKeys.ts b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerXKeys.ts new file mode 100644 index 0000000..84f2ede --- /dev/null +++ b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerXKeys.ts @@ -0,0 +1,252 @@ +import { assertNever } from '@sofie-prompter-editor/shared-lib' +import { getOpenedXKeysPanels, requestXkeysPanels, setupXkeysPanel, XKeys } from 'xkeys-webhid' +import { TriggerHandler } from './TriggerHandler' +import { TriggerConfig, TriggerConfigType, TriggerConfigXkeys } from '../triggerConfig' +import { AnyTriggerAction } from '../../triggerActions/triggerActions' + +export class TriggerHandlerXKeys extends TriggerHandler { + private neededPanelIds = new Set<{ + productId: number | null + unitId: number | null + }>() + + private connectedPanels: XKeys[] = [] + + private triggerKeys: TriggerConfigXkeys[] = [] + private triggerAnalog: TriggerConfigXkeys[] = [] + private triggerXYZ: TriggerConfigXkeys[] = [] + + async initialize(triggers?: TriggerConfig[]): Promise { + if (triggers) this.triggers = triggers + // Make list of which panels we have triggers for: + for (const trigger of this.triggers) { + if (trigger.type !== TriggerConfigType.XKEYS) continue + this.neededPanelIds.add({ productId: trigger.productId, unitId: trigger.unitId }) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const theWindow = window as any + + // hot-module-reload fix: + if (!theWindow.xkeysInitialized) { + theWindow.xkeysInitialized = true + theWindow.xkeysConnectedPanels = this.connectedPanels + + // Get list of already opened panels and connect to them: + const alreadyOpenedDevices = await getOpenedXKeysPanels() + for (const device of alreadyOpenedDevices) { + await this.connectToHIDDevice(device) + } + + if (this.neededPanelIds.size > 0) { + // We have triggers setup for panels we don't have access to. + // Emit an event which will prompt the user to grant access: + this.emit('requestHIDDeviceAccess', 'XKeys', (allow) => { + this.allowAccess(allow) + }) + } + } else { + this.connectedPanels = theWindow.xkeysConnectedPanels + } + + this.triggerKeys = [] + this.triggerAnalog = [] + this.triggerXYZ = [] + for (const trigger of this.triggers) { + if (trigger.type !== TriggerConfigType.XKEYS) continue + + if (trigger.eventType === 'down' || trigger.eventType === 'up') { + this.triggerKeys.push(trigger) + } else if ( + trigger.eventType === 'jog' || + trigger.eventType === 'rotary' || + trigger.eventType === 'shuttle' || + trigger.eventType === 'tbar' + ) { + this.triggerAnalog.push(trigger) + } else if (trigger.eventType === 'trackball' || trigger.eventType === 'joystick') { + this.triggerXYZ.push(trigger) + } else { + assertNever(trigger.eventType) + } + } + + for (const xkeys of this.connectedPanels) { + xkeys.removeAllListeners('down') + xkeys.removeAllListeners('up') + xkeys.removeAllListeners('jog') + xkeys.removeAllListeners('rotary') + xkeys.removeAllListeners('shuttle') + xkeys.removeAllListeners('tbar') + xkeys.removeAllListeners('trackball') + xkeys.removeAllListeners('joystick') + + xkeys.setAllBacklights(false) + + xkeys.on('error', (e) => { + console.error('xkeys error', e) + }) + xkeys.on('down', (keyIndex: number) => { + const action = this.getKeyAction('down', keyIndex) + if (action) this.emit('action', action) + }) + xkeys.on('up', (keyIndex: number) => { + const action = this.getKeyAction('up', keyIndex) + if (action) this.emit('action', action) + }) + xkeys.on('jog', (index, value) => { + const action = this.getAnalogAction('jog', index, value, 7) + if (action) this.emit('action', action) + }) + xkeys.on('rotary', (index, value) => { + const action = this.getAnalogAction('rotary', index, value, 8) + if (action) this.emit('action', action) + }) + xkeys.on('shuttle', (index, value) => { + const action = this.getAnalogAction('shuttle', index, value, 7) + if (action) this.emit('action', action) + }) + xkeys.on('tbar', (index, value) => { + const action = this.getAnalogAction('tbar', index, value, 127, 127) + if (action) this.emit('action', action) + }) + xkeys.on('trackball', (index, value) => { + const action = this.getXYZAction('trackball', index, value) + if (action) this.emit('action', action) + }) + + xkeys.on('joystick', (index, value) => { + const action = this.getXYZAction('joystick', index, value) + if (action) this.emit('action', action) + }) + } + } + allowAccess(allow: boolean) { + if (allow) { + this.requestXkeysPanels().catch(console.error) + } + } + private async requestXkeysPanels() { + // Must be handling a user gesture to show a permission request + + // Connect to a new panel: + const newDevices = await requestXkeysPanels() + for (const device of newDevices) { + await this.connectToHIDDevice(device) + } + await this.initialize() + } + async destroy(): Promise { + await Promise.all(this.connectedPanels.map((xkeys) => xkeys.close())) + } + + private async connectToHIDDevice(device: HIDDevice) { + const xkeys = await setupXkeysPanel(device) + + const matches = this.matchNeededPanel(xkeys.info.productId, xkeys.info.unitId) + for (const match of matches) { + this.neededPanelIds.delete(match) + } + if (matches.length > 0) { + this.connectedPanels.push(xkeys) + } else { + await xkeys.close() + } + } + + private matchNeededPanel(productId: number, unitId: number) { + const matched: { + productId: number | null + unitId: number | null + }[] = [] + for (const needed of this.neededPanelIds.values()) { + if ( + (needed.productId === null || needed.productId === productId) && + (needed.unitId === null || needed.unitId === unitId) + ) { + matched.push(needed) + } + } + return matched + } + /** Generate an action from a key input */ + private getKeyAction(eventType: string, keyIndex: number): AnyTriggerAction | undefined { + const trigger: TriggerConfigXkeys | undefined = this.triggerKeys.find( + (t) => t.eventType === eventType && t.index === keyIndex + ) + if (!trigger) return undefined + + if ('payload' in trigger.action) return trigger.action // Already defined, just pass through + + if (trigger.action.type === 'prompterMove') { + // ignore + } else if (trigger.action.type === 'prompterAccelerate') { + // ignore + } else if (trigger.action.type === 'prompterJump') { + // ignore + } else if (trigger.action.type === 'movePrompterToHere') { + return { + type: 'movePrompterToHere', + payload: {}, + } + } else { + assertNever(trigger.action.type) + } + return undefined + } + /** Generate an action from a "analog type" input */ + private getAnalogAction( + eventType: string, + index: number, + value: number, + scaleMaxValue = 1, + zeroValue = 0 + ): AnyTriggerAction | undefined { + const trigger: TriggerConfigXkeys | undefined = this.triggerAnalog.find( + (t) => t.eventType === eventType && t.index === index + ) + if (!trigger) return undefined + + if ('payload' in trigger.action) return trigger.action // Already defined, just pass through + + if (trigger.action.type === 'prompterMove') { + const normalValue = (value - zeroValue) / scaleMaxValue + return { + type: 'prompterMove', + payload: { speed: normalValue }, + } + } else if (trigger.action.type === 'prompterAccelerate') { + const normalValue = (value - zeroValue) / scaleMaxValue + return { + type: 'prompterAccelerate', + payload: { accelerate: normalValue }, + } + } + return undefined + } + /** Generate an action from a "XYZ type" input */ + private getXYZAction( + eventType: string, + index: number, + xyz: { x: number; y: number; z?: number } + ): AnyTriggerAction | undefined { + const trigger: TriggerConfigXkeys | undefined = this.triggerXYZ.find( + (t) => t.eventType === eventType && t.index === index + ) + if (!trigger) return undefined + if ('payload' in trigger.action) return trigger.action // Already defined, just pass through + + if (trigger.action.type === 'prompterMove') { + return { + type: 'prompterMove', + payload: { speed: xyz.y }, + } + } else if (trigger.action.type === 'prompterAccelerate') { + return { + type: 'prompterAccelerate', + payload: { accelerate: xyz.y }, + } + } + return undefined + } +} diff --git a/packages/apps/client/src/lib/triggers/triggers.ts b/packages/apps/client/src/lib/triggers/triggers.ts new file mode 100644 index 0000000..31f6b08 --- /dev/null +++ b/packages/apps/client/src/lib/triggers/triggers.ts @@ -0,0 +1,114 @@ +import { TriggerConfig, TriggerConfigType } from './triggerConfig.ts' + +// We might move these to a config file later: +export const hardCodedTriggers: TriggerConfig[] = [ + { + type: TriggerConfigType.KEYBOARD, + keys: 'ArrowUp', + action: { + type: 'prompterMove', + payload: { + speed: -3, + }, + }, + }, + { + type: TriggerConfigType.KEYBOARD, + keys: 'ArrowDown', + action: { + type: 'prompterMove', + payload: { + speed: 3, + }, + }, + }, + { + type: TriggerConfigType.XKEYS, + productId: null, + unitId: null, + eventType: 'down', + index: 1, + action: { + type: 'prompterMove', + payload: { + speed: -3, + }, + }, + }, + { + type: TriggerConfigType.XKEYS, + productId: null, + unitId: null, + eventType: 'down', + index: 2, + action: { + type: 'prompterMove', + payload: { + speed: 3, + }, + }, + }, + { + type: TriggerConfigType.XKEYS, + productId: null, + unitId: null, + eventType: 'tbar', + index: 0, + action: { + type: 'prompterMove', + }, + }, + { + type: TriggerConfigType.XKEYS, + productId: null, + unitId: null, + eventType: 'jog', + index: 0, + action: { + type: 'prompterAccelerate', + }, + }, + { + type: TriggerConfigType.XKEYS, + productId: null, + unitId: null, + eventType: 'shuttle', + index: 0, + action: { + type: 'prompterMove', + }, + }, + { + type: TriggerConfigType.STREAMDECK, + modelId: null, + serialNumber: null, + eventType: 'down', + index: 0, + action: { + type: 'prompterMove', + payload: { + speed: 3, + }, + }, + }, + { + type: TriggerConfigType.STREAMDECK, + modelId: null, + serialNumber: null, + eventType: 'rotate', + index: 0, + action: { + type: 'prompterMove', + }, + }, + { + type: TriggerConfigType.SPACEMOUSE, + productId: null, + unitId: null, + eventType: 'rotate', + index: 0, + action: { + type: 'prompterMove', + }, + }, +] diff --git a/packages/apps/client/src/stores/RootAppStore.ts b/packages/apps/client/src/stores/RootAppStore.ts index 8bd96e5..c5ab7b4 100644 --- a/packages/apps/client/src/stores/RootAppStore.ts +++ b/packages/apps/client/src/stores/RootAppStore.ts @@ -19,6 +19,8 @@ import { import { OutputSettingsStore } from './OutputSettingsStore.ts' import { SystemStatusStore } from './SystemStatusStore.ts' import { ViewPortStore } from './ViewportStateStore.ts' +import { TriggerStore } from './TriggerStore.ts' +import { TriggerActionHandler } from '../lib/triggerActions/TriggerActionHandler.ts' const USE_MOCK_CONNECTION = false @@ -30,8 +32,11 @@ class RootAppStoreClass { systemStatusStore: SystemStatusStore outputSettingsStore: OutputSettingsStore viewportStore: ViewPortStore + triggerStore: TriggerStore uiStore: UIStore + private triggerActionHandler: TriggerActionHandler + constructor() { makeObservable(this, { connected: observable, @@ -45,16 +50,17 @@ class RootAppStoreClass { this.systemStatusStore = new SystemStatusStore(this, this.connection) this.outputSettingsStore = new OutputSettingsStore(this, this.connection) this.viewportStore = new ViewPortStore(this, this.connection) + this.triggerStore = new TriggerStore(this, this.connection) this.uiStore = new UIStore() this.connection.on('disconnected', this.onDisconnected) - this.connection.on('connected', this.onConnected) this.connection.systemStatus.subscribe() this.connection.systemStatus.on('updated', this.onSystemStatusUpdated) - this.connection.systemStatus.get(null).then(this.onSystemStatusUpdated) + + this.triggerActionHandler = new TriggerActionHandler(this) } onSystemStatusUpdated = action( diff --git a/packages/apps/client/src/stores/TriggerStore.ts b/packages/apps/client/src/stores/TriggerStore.ts new file mode 100644 index 0000000..39eb90a --- /dev/null +++ b/packages/apps/client/src/stores/TriggerStore.ts @@ -0,0 +1,77 @@ +import { observable, action } from 'mobx' +import { EventEmitter } from 'eventemitter3' + +import { APIConnection, RootAppStore } from './RootAppStore' +import { hardCodedTriggers } from '../lib/triggers/triggers' + +import { TriggerHandlerEvents } from '../lib/triggers/triggerHandlers/TriggerHandler' +import { TriggerHandlerXKeys } from '../lib/triggers/triggerHandlers/TriggerHandlerXKeys' +import { TriggerHandlerKeyboard } from '../lib/triggers/triggerHandlers/TriggerHandlerKeyboard' +import { TriggerHandlerStreamdeck } from '../lib/triggers/triggerHandlers/TriggerHandlerStreamdeck' +import { TriggerHandlerSpaceMouse } from '../lib/triggers/triggerHandlers/TriggerHandlerSpaceMouse' + +export interface TriggerStoreEvents { + action: TriggerHandlerEvents['action'] +} +/** + * The TriggerStore is responsible for listening to triggers (eg keyboard shortcuts) and dispatching action events + */ +export class TriggerStore extends EventEmitter { + /** When set, indicates that a TriggerHandler wants to request access to HIDDevices */ + public hidDeviceAccessRequests = observable.map< + string, + { + deviceName: string + callback: (allow: boolean) => void + } + >() + + private triggers = hardCodedTriggers + private triggerHandlers = [ + new TriggerHandlerKeyboard(), + new TriggerHandlerXKeys(), + new TriggerHandlerStreamdeck(), + new TriggerHandlerSpaceMouse(), + ] + + constructor(public appStore: typeof RootAppStore, public connection: APIConnection) { + super() + + for (const triggerHandler of this.triggerHandlers) { + // Just pipe action events through: + triggerHandler.on('action', (...args) => this.emit('action', ...args)) + + // Handle requests for HIDDevice access: + // HIDDevice access requests can only be initiated by a user action, + // this event indicates that a TriggerHandler wants to request access to a HIDDevice + // The user will then be prompted to allow access to the device, and the callback will be called + triggerHandler.on( + 'requestHIDDeviceAccess', + action((deviceName: string, callback: (allow: boolean) => void) => { + const key = triggerHandler.constructor.name + this.hidDeviceAccessRequests.set(key, { + deviceName, + callback: (allow: boolean) => { + this.hidDeviceAccessRequests.delete(key) + callback(allow) + }, + }) + }) + ) + } + + this.initialize().catch(console.error) + } + + private async initialize() { + for (const triggerHandler of this.triggerHandlers) { + await triggerHandler.initialize(this.triggers) + } + } + + async destroy() { + for (const triggerHandler of this.triggerHandlers) { + await triggerHandler.destroy() + } + } +} diff --git a/yarn.lock b/yarn.lock index e032bef..c465c10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -464,6 +464,28 @@ __metadata: languageName: node linkType: hard +"@elgato-stream-deck/core@npm:6.0.0": + version: 6.0.0 + resolution: "@elgato-stream-deck/core@npm:6.0.0" + dependencies: + eventemitter3: "npm:^4.0.7" + tslib: "npm:^2.6.2" + checksum: ef3dcbad3cb0118b870c84669b437d124d68b79f6970a77c70a7754dfd7d731afbd3ffc2c51168b62987d2432765743eb95f5ada7deb0882ebb82990277b1607 + languageName: node + linkType: hard + +"@elgato-stream-deck/webhid@npm:^6.0.0": + version: 6.0.0 + resolution: "@elgato-stream-deck/webhid@npm:6.0.0" + dependencies: + "@elgato-stream-deck/core": "npm:6.0.0" + "@types/w3c-web-hid": "npm:^1.0.6" + p-queue: "npm:^6.6.2" + tslib: "npm:^2.6.2" + checksum: 31a4074f22166890de34e0a413357405729642c287f23c0f6477f168981d766962a7c92e7c19e33e3d381e9b442d54474e034ee283a06a992beb6616d89923ad + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/android-arm64@npm:0.18.20" @@ -1924,6 +1946,13 @@ __metadata: languageName: node linkType: hard +"@sofie-automation/sorensen@npm:^1.4.2": + version: 1.4.2 + resolution: "@sofie-automation/sorensen@npm:1.4.2" + checksum: e011d3d70083519966906a19cbe340480769b013e8d2f4e59b8cf21afb3dbd8d19c6bd4f01d3112dc96f58264961cf4c3fe52ecd1d2725be7254a209e00c61ae + languageName: node + linkType: hard + "@sofie-prompter-editor/apps-app@workspace:packages/apps/app": version: 0.0.0-use.local resolution: "@sofie-prompter-editor/apps-app@workspace:packages/apps/app" @@ -1969,8 +1998,10 @@ __metadata: version: 0.0.0-use.local resolution: "@sofie-prompter-editor/apps-client@workspace:packages/apps/client" dependencies: + "@elgato-stream-deck/webhid": "npm:^6.0.0" "@popperjs/core": "npm:^2.11.8" "@react-hook/resize-observer": "npm:^1.2.6" + "@sofie-automation/sorensen": "npm:^1.4.2" "@sofie-prompter-editor/shared-lib": "npm:0.0.0" "@sofie-prompter-editor/shared-model": "npm:0.0.0" "@types/bootstrap": "npm:^5" @@ -1981,6 +2012,7 @@ __metadata: "@typescript-eslint/parser": "npm:^6.10.0" "@vitejs/plugin-react": "npm:^4.1.1" bootstrap: "npm:^5.3.2" + buffer: "npm:^6.0.3" eslint: "npm:^8.53.0" eslint-plugin-react-hooks: "npm:^4.6.0" eslint-plugin-react-refresh: "npm:^0.4.4" @@ -2003,9 +2035,11 @@ __metadata: react-router-dom: "npm:^6.18.0" sass: "npm:^1.69.5" socket.io-client: "npm:^4.7.2" + spacemouse-webhid: "npm:^0.0.2" typescript: "npm:^5.2.2" uuid: "npm:^9.0.1" vite: "npm:^4.5.0" + xkeys-webhid: "npm:^3.1.0" languageName: unknown linkType: soft @@ -2027,6 +2061,15 @@ __metadata: languageName: unknown linkType: soft +"@spacemouse-lib/core@npm:0.0.2": + version: 0.0.2 + resolution: "@spacemouse-lib/core@npm:0.0.2" + dependencies: + tslib: "npm:^2.4.0" + checksum: c5cab7ee31d06e011763948dde032af9ede3a47fb9024caae0434a4bb4e303b4f9a5b493ace6b7fb07ddb51206982095498150f942802bed13b1b7f90cc6575e + languageName: node + linkType: hard + "@swc/helpers@npm:^0.5.0": version: 0.5.3 resolution: "@swc/helpers@npm:0.5.3" @@ -2622,6 +2665,13 @@ __metadata: languageName: node linkType: hard +"@types/w3c-web-hid@npm:^1.0.3, @types/w3c-web-hid@npm:^1.0.6": + version: 1.0.6 + resolution: "@types/w3c-web-hid@npm:1.0.6" + checksum: 8e92c302708771cc154c26b5033e11530f465143c1a3c78dc779208d828491e44a9b2f02fac720b1de6a8d93d6daf82853758aaf122aeecc64bfb8431045d3ae + languageName: node + linkType: hard + "@types/warning@npm:^3.0.0": version: 3.0.3 resolution: "@types/warning@npm:3.0.3" @@ -2919,6 +2969,15 @@ __metadata: languageName: node linkType: hard +"@xkeys-lib/core@npm:3.1.0": + version: 3.1.0 + resolution: "@xkeys-lib/core@npm:3.1.0" + dependencies: + tslib: "npm:^2.4.0" + checksum: a780ee0e41ea8813106248844dc210a175e1028f895448860bde427ab44299a82c9272ce3a6d5a576493c346520578004fd4417fcaf3dd728859246baf5a1bfa + languageName: node + linkType: hard + "@yarnpkg/lockfile@npm:^1.1.0": version: 1.1.0 resolution: "@yarnpkg/lockfile@npm:1.1.0" @@ -3484,6 +3543,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.2.1" + checksum: 2a905fbbcde73cc5d8bd18d1caa23715d5f83a5935867c2329f0ac06104204ba7947be098fe1317fbd8830e26090ff8e764f08cd14fefc977bb248c3487bcbd0 + languageName: node + linkType: hard + "builtins@npm:^1.0.3": version: 1.0.3 resolution: "builtins@npm:1.0.3" @@ -6203,7 +6272,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.1.13": +"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb @@ -9208,7 +9277,7 @@ __metadata: languageName: node linkType: hard -"p-queue@npm:6.6.2": +"p-queue@npm:6.6.2, p-queue@npm:^6.6.2": version: 6.6.2 resolution: "p-queue@npm:6.6.2" dependencies: @@ -10767,6 +10836,18 @@ __metadata: languageName: node linkType: hard +"spacemouse-webhid@npm:^0.0.2": + version: 0.0.2 + resolution: "spacemouse-webhid@npm:0.0.2" + dependencies: + "@spacemouse-lib/core": "npm:0.0.2" + "@types/w3c-web-hid": "npm:^1.0.3" + buffer: "npm:^6.0.3" + p-queue: "npm:^6.6.2" + checksum: 32c7f5efb55ad726fa5f3166da52ffb4774be16f20f4f49aba047acd707677a2a71a8a47fbbe32e09e1527532cf4f56a9d880b4d1790d69c3dad668ee396443a + languageName: node + linkType: hard + "spawn-command@npm:0.0.2": version: 0.0.2 resolution: "spawn-command@npm:0.0.2" @@ -11361,7 +11442,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.5.1": +"tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.5.1, tslib@npm:^2.6.2": version: 2.6.2 resolution: "tslib@npm:2.6.2" checksum: e03a8a4271152c8b26604ed45535954c0a45296e32445b4b87f8a5abdb2421f40b59b4ca437c4346af0f28179780d604094eb64546bee2019d903d01c6c19bdb @@ -12026,6 +12107,18 @@ __metadata: languageName: node linkType: hard +"xkeys-webhid@npm:^3.1.0": + version: 3.1.0 + resolution: "xkeys-webhid@npm:3.1.0" + dependencies: + "@types/w3c-web-hid": "npm:^1.0.3" + "@xkeys-lib/core": "npm:3.1.0" + buffer: "npm:^6.0.3" + p-queue: "npm:^6.6.2" + checksum: f3c20a4e93fc1389d028b52d1fe1f23defcd311a9f6e21fbe2b9aa9da2b03605bf412b9978c7cc42ff18e19ed4039d9809860006f6a054fde6fff1eb795d7b39 + languageName: node + linkType: hard + "xmlhttprequest-ssl@npm:~2.0.0": version: 2.0.0 resolution: "xmlhttprequest-ssl@npm:2.0.0"