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"