diff --git a/packages/apps/client/package.json b/packages/apps/client/package.json index bc0be87..13fae9a 100644 --- a/packages/apps/client/package.json +++ b/packages/apps/client/package.json @@ -34,11 +34,13 @@ "vite": "^4.5.0" }, "dependencies": { + "@elgato-stream-deck/webhid": "^6.0.0", "@popperjs/core": "^2.11.8", "@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 +58,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 2681c89..cbf8ac2 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 { 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..1d7e92e 100644 --- a/packages/apps/client/src/components/SystemStatusAlertBars/SystemStatusAlertBars.tsx +++ b/packages/apps/client/src/components/SystemStatusAlertBars/SystemStatusAlertBars.tsx @@ -2,14 +2,57 @@ 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 xKeysRequestsAccess = RootAppStore.triggerStore.xKeysRequestsAccess + const streamdeckRequestsAccess = RootAppStore.triggerStore.streamdeckRequestsAccess + const spacemouseRequestsAccess = RootAppStore.triggerStore.spacemouseRequestsAccess + + let requestAccess: { + name: string + allow: () => void + deny: () => void + } | null = null + + if (xKeysRequestsAccess) { + requestAccess = { + name: 'X-Keys panels', + allow: () => RootAppStore.triggerStore.xkeysAccess(true), + deny: () => RootAppStore.triggerStore.xkeysAccess(false), + } + } else if (streamdeckRequestsAccess) { + requestAccess = { + name: 'Streamdeck panels', + allow: () => RootAppStore.triggerStore.streamdeckAccess(true), + deny: () => RootAppStore.triggerStore.streamdeckAccess(false), + } + } else if (spacemouseRequestsAccess) { + requestAccess = { + name: 'SpaceMouse devices', + allow: () => RootAppStore.triggerStore.spacemouseAccess(true), + deny: () => RootAppStore.triggerStore.spacemouseAccess(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/lib/triggers/triggerActions.ts b/packages/apps/client/src/lib/triggers/triggerActions.ts index 37bffe7..811c03a 100644 --- a/packages/apps/client/src/lib/triggers/triggerActions.ts +++ b/packages/apps/client/src/lib/triggers/triggerActions.ts @@ -1,12 +1,22 @@ /* eslint-disable no-mixed-spaces-and-tabs */ -export type TriggerAction = - | { - type: 'prompterMove' - /** The speed to move the prompter */ - speed: number - } - | { - type: 'prompterJump' - // todo - } +export type AnyTriggerAction = + | TriggerAction< + 'prompterMove', + { + /** The speed to move the prompter */ + speed: number + } + > + | TriggerAction< + 'prompterJump', + { + // todo + } + > + | TriggerAction<'movePrompterToHere', {}> + +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 index 0c539f1..abbb72e 100644 --- a/packages/apps/client/src/lib/triggers/triggerConfig.ts +++ b/packages/apps/client/src/lib/triggers/triggerConfig.ts @@ -1,21 +1,26 @@ -import {BindOptions} from '@sofie-automation/sorensen' -import { TriggerAction } from './triggerActions.ts' +import { BindOptions } from '@sofie-automation/sorensen' +import { AnyTriggerAction } from './triggerActions.ts' +import { DeviceModelId } from '@elgato-stream-deck/webhid' - - -export type TriggerConfig = TriggerConfigKeyboard | TriggerConfigXkeys +export type TriggerConfig = + | TriggerConfigKeyboard + | TriggerConfigXkeys + | TriggerConfigStreamdeck + | TriggerConfigSpacemouse export enum TriggerConfigType { KEYBOARD = 'keyboard', XKEYS = 'xkeys', + STREAMDECK = 'streamdeck', + SPACEMOUSE = 'spacemouse', } export interface TriggerConfigBase { type: TriggerConfigType - - action: TriggerAction } 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". @@ -33,10 +38,65 @@ export interface TriggerConfigKeyboard extends TriggerConfigBase { up?: boolean global?: BindOptions['global'] - } export interface TriggerConfigXkeys extends TriggerConfigBase { type: TriggerConfigType.XKEYS - // Todo + /** 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..30e0e5f --- /dev/null +++ b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandler.ts @@ -0,0 +1,17 @@ +import { EventEmitter } from 'eventemitter3' +import { AnyTriggerAction } from '../triggerActions' +import { TriggerConfig } from '../triggerConfig.ts' + +export interface TriggerHandlerEvents { + action: [action: AnyTriggerAction] + + requestXkeysAccess: [] + requestStreamdeckAccess: [] + requestSpacemouseAccess: [] +} + +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..059dd92 --- /dev/null +++ b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerKeyboard.ts @@ -0,0 +1,40 @@ +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 + // hot-module-reload fix: + if (!(window as any).sorensenInitialized) { + ;(window as any).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..23e154d --- /dev/null +++ b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerSpaceMouse.ts @@ -0,0 +1,176 @@ +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' + +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 }) + } + + // hot-module-reload fix: + if (!(window as any).spacemouseInitialized) { + ;(window as any).spacemouseInitialized = true + ;(window as any).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('requestSpacemouseAccess') + } + } else { + this.connectedPanels = (window as any).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 === '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 }, + } + } + 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..0159f7a --- /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, + openDevice, + DeviceModelId, + StreamDeckWeb, +} from '@elgato-stream-deck/webhid' +import { TriggerHandler } from './TriggerHandler' +import { TriggerConfig, TriggerConfigStreamdeck, TriggerConfigType } from '../triggerConfig' +import { AnyTriggerAction } from '../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 }) + } + + // hot-module-reload fix: + if (!(window as any).streamdeckInitialized) { + ;(window as any).streamdeckInitialized = true + ;(window as any).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('requestStreamdeckAccess') + } + } else { + this.connectedPanels = (window as any).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 === '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..5ece213 --- /dev/null +++ b/packages/apps/client/src/lib/triggers/triggerHandlers/TriggerHandlerXKeys.ts @@ -0,0 +1,227 @@ +import { assertNever } from '@sofie-prompter-editor/shared-lib' +import { getOpenedXKeysPanels, requestXkeysPanels, setupXkeysPanel, TrackballValue, XKeys } from 'xkeys-webhid' +import { TriggerHandler } from './TriggerHandler' +import { TriggerConfig, TriggerConfigType, TriggerConfigXkeys } from '../triggerConfig' +import { AnyTriggerAction } from '../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 }) + } + + // hot-module-reload fix: + if (!(window as any).xkeysInitialized) { + ;(window as any).xkeysInitialized = true + ;(window as any).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('requestXkeysAccess') + } + } else { + this.connectedPanels = (window as any).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) + if (action) this.emit('action', action) + }) + xkeys.on('rotary', (index, value) => { + const action = this.getAnalogAction('rotary', index, value) + if (action) this.emit('action', action) + }) + xkeys.on('shuttle', (index, value) => { + const action = this.getAnalogAction('shuttle', index, value) + if (action) this.emit('action', action) + }) + xkeys.on('tbar', (index, value) => { + const action = this.getAnalogAction('tbar', index, value) + 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 === '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: 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') { + return { + type: 'prompterMove', + payload: { speed: value }, + } + } + 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 }, + } + } + return undefined + } +} diff --git a/packages/apps/client/src/lib/triggers/triggers.ts b/packages/apps/client/src/lib/triggers/triggers.ts index cdb9b10..e3290d8 100644 --- a/packages/apps/client/src/lib/triggers/triggers.ts +++ b/packages/apps/client/src/lib/triggers/triggers.ts @@ -1,12 +1,15 @@ import { TriggerConfig, TriggerConfigType } from './triggerConfig.ts' -export const triggers: TriggerConfig[] = [ +// We might move these to a config file later: +export const hardCodedTriggers: TriggerConfig[] = [ { type: TriggerConfigType.KEYBOARD, keys: 'ArrowUp', action: { type: 'prompterMove', - speed: -3, + payload: { + speed: -3, + }, }, }, { @@ -14,7 +17,68 @@ export const triggers: TriggerConfig[] = [ keys: 'ArrowDown', action: { type: 'prompterMove', - speed: 3, + 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.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 946d413..b012b32 100644 --- a/packages/apps/client/src/stores/RootAppStore.ts +++ b/packages/apps/client/src/stores/RootAppStore.ts @@ -48,13 +48,15 @@ class RootAppStoreClass { 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.triggerStore.on('action', (action) => { + console.log('TriggerStore action', JSON.stringify(action)) + }) } onSystemStatusUpdated = action( diff --git a/packages/apps/client/src/stores/TriggerStore.ts b/packages/apps/client/src/stores/TriggerStore.ts index 59285ef..4644f1c 100644 --- a/packages/apps/client/src/stores/TriggerStore.ts +++ b/packages/apps/client/src/stores/TriggerStore.ts @@ -1,82 +1,95 @@ import { observable, action, flow, makeObservable, IReactionDisposer, reaction } from 'mobx' -import Sorensen from '@sofie-automation/sorensen' -import { OutputSettings } from '@sofie-prompter-editor/shared-model' -import { APIConnection, RootAppStore } from './RootAppStore.ts' -import { triggers } from '../lib/triggers/triggers.ts' -import { TriggerConfigType } from '../lib/triggers/triggerConfig.ts' +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 { +export class TriggerStore extends EventEmitter { // initialized = false // private initializing = false - reactions: IReactionDisposer[] = [] - listeners: { - actionListener: () => void - }[] = [] + /** Is true when if xkeys requests access to HIDDevices */ + public xKeysRequestsAccess = false + /** Is true when if streamdeck requests access to HIDDevices */ + public streamdeckRequestsAccess = false + /** Is true when if space mouse requests access to HIDDevices */ + public spacemouseRequestsAccess = false - private sorensen = Sorensen + reactions: IReactionDisposer[] = [] - private triggers = triggers + private triggers = hardCodedTriggers + private keyboard = new TriggerHandlerKeyboard() + private xkeys = new TriggerHandlerXKeys() + private streamdeck = new TriggerHandlerStreamdeck() + private spacemouse = new TriggerHandlerSpaceMouse() constructor(public appStore: typeof RootAppStore, public connection: APIConnection) { + super() makeObservable(this, { - // outputSettings: observable, + xKeysRequestsAccess: observable, + streamdeckRequestsAccess: observable, // initialized: observable, }) - this.initialize().catch(console.error) - } + this.keyboard.on('action', (...args) => this.emit('action', ...args)) + this.xkeys.on('action', (...args) => this.emit('action', ...args)) + this.xkeys.on( + 'requestXkeysAccess', + action(() => { + this.xKeysRequestsAccess = true + }) + ) + this.streamdeck.on('action', (...args) => this.emit('action', ...args)) + this.streamdeck.on( + 'requestStreamdeckAccess', + action(() => { + this.streamdeckRequestsAccess = true + }) + ) + this.spacemouse.on('action', (...args) => this.emit('action', ...args)) + this.spacemouse.on( + 'requestSpacemouseAccess', + action(() => { + this.spacemouseRequestsAccess = true + }) + ) - public async initialize() { - // if (!this.initializing && !this.initialized) { - // this.initializing = true - // this.setupSubscription() - // this.loadInitialData() - // } + this.initialize().catch(console.error) } - public async setupKeyboard() { - await Sorensen.init() - - for (const trigger of triggers) { - if (trigger.type !== TriggerConfigType.KEYBOARD) continue - - const actionListener = () => {} - - this.sorensen.bind(trigger.keys, actionListener, { - up: trigger.up, - exclusive: true, - ordered: 'modifiersFirst', - preventDefaultPartials: false, - preventDefaultDown: true, - global: trigger.global, - }) - - this.listeners.push({ - actionListener, - }) - } + public xkeysAccess = action((allow: boolean) => { + this.xKeysRequestsAccess = false + this.xkeys.allowAccess(allow) + }) + public streamdeckAccess = action((allow: boolean) => { + this.streamdeckRequestsAccess = false + this.streamdeck.allowAccess(allow) + }) + public spacemouseAccess = action((allow: boolean) => { + this.spacemouseRequestsAccess = false + this.spacemouse.allowAccess(allow) + }) - // sorensen.bind('Shift', onKey, { - // up: false, - // global: true, - // }) - // this.sorensen.bind(Settings.confirmKeyCode, this.preventDefault, { - // up: false, - // prepend: true, - // }) - // this.sorensen.bind(Settings.confirmKeyCode, this.handleKey, { - // up: true, - // prepend: true, - // }) + private async initialize() { + await this.keyboard.initialize(this.triggers) + await this.xkeys.initialize(this.triggers) + await this.streamdeck.initialize(this.triggers) + await this.spacemouse.initialize(this.triggers) } - public async setupXKeys() {} destroy = () => { this.reactions.forEach((dispose) => dispose()) - this.sorensen.destroy().catch((e: Error) => console.error('Sorensen.destroy', e)) } } diff --git a/yarn.lock b/yarn.lock index 0e77b7b..bb63479 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" @@ -1938,6 +1960,7 @@ __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" "@sofie-automation/sorensen": "npm:^1.4.2" "@sofie-prompter-editor/shared-lib": "npm:0.0.0" @@ -1950,6 +1973,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" @@ -1972,9 +1996,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 @@ -1996,6 +2022,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" @@ -2591,6 +2626,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" @@ -2888,6 +2930,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" @@ -3453,6 +3504,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" @@ -6172,7 +6233,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 @@ -9177,7 +9238,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: @@ -10736,6 +10797,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" @@ -11330,7 +11403,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 @@ -11995,6 +12068,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"