Skip to content

Commit 4f40cea

Browse files
committed
feat(EAV-297): add Shuttle WebHID prompter controller support
1 parent b51ee26 commit 4f40cea

File tree

6 files changed

+285
-2
lines changed

6 files changed

+285
-2
lines changed

packages/webui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
"react-timer-hoc": "^2.3.0",
7878
"semver": "^7.6.3",
7979
"sha.js": "^2.4.11",
80+
"shuttle-webhid": "^0.0.2",
8081
"type-fest": "^3.13.1",
8182
"underscore": "^1.13.7",
8283
"velocity-animate": "^1.5.2",

packages/webui/src/client/styles/prompter.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,16 @@ body.prompter-scrollbar {
265265
}
266266
}
267267

268+
#prompter-device-access {
269+
position: fixed;
270+
top: 0;
271+
left: 0;
272+
padding: 0.7em;
273+
button {
274+
background: black;
275+
}
276+
}
277+
268278
.prompter-timing-clock {
269279
position: fixed;
270280
display: block;

packages/webui/src/client/ui/Prompter/PrompterView.tsx

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export enum PrompterConfigMode {
7474
SHUTTLEKEYBOARD = 'shuttlekeyboard',
7575
JOYCON = 'joycon',
7676
PEDAL = 'pedal',
77+
SHUTTLEWEBHID = 'shuttlewebhid',
7778
}
7879

7980
export interface IPrompterControllerState {
@@ -92,6 +93,15 @@ interface ITrackedProps {
9293
subsReady: boolean
9394
}
9495

96+
export interface AccessRequestCallback {
97+
deviceName: string
98+
callback: () => void
99+
}
100+
101+
interface IState {
102+
accessRequestCallbacks: AccessRequestCallback[]
103+
}
104+
95105
function asArray<T>(value: T | T[] | null): T[] {
96106
if (Array.isArray(value)) {
97107
return value
@@ -102,7 +112,7 @@ function asArray<T>(value: T | T[] | null): T[] {
102112
}
103113
}
104114

105-
export class PrompterViewContent extends React.Component<Translated<IProps & ITrackedProps>> {
115+
export class PrompterViewContent extends React.Component<Translated<IProps & ITrackedProps>, IState> {
106116
autoScrollPreviousPartInstanceId: PartInstanceId | null = null
107117

108118
configOptions: PrompterConfig
@@ -115,7 +125,7 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
115125
constructor(props: Translated<IProps & ITrackedProps>) {
116126
super(props)
117127
this.state = {
118-
subsReady: false,
128+
accessRequestCallbacks: [],
119129
}
120130
// Disable the context menu:
121131
document.addEventListener('contextmenu', (e) => {
@@ -287,6 +297,19 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
287297
// margin in pixels
288298
return ((this.configOptions.margin || 0) * window.innerHeight) / 100
289299
}
300+
301+
public registerAccessRequestCallback(callback: AccessRequestCallback): void {
302+
this.setState((state) => ({
303+
accessRequestCallbacks: [...state.accessRequestCallbacks, callback],
304+
}))
305+
}
306+
307+
public unregisterAccessRequestCallback(callback: AccessRequestCallback): void {
308+
this.setState((state) => ({
309+
accessRequestCallbacks: state.accessRequestCallbacks.filter((candidate) => candidate !== callback),
310+
}))
311+
}
312+
290313
scrollToPartInstance(partInstanceId: PartInstanceId): void {
291314
const scrollMargin = this.calculateScrollPosition()
292315
const target = document.querySelector<HTMLElement>(`[data-part-instance-id="${partInstanceId}"]`)
@@ -461,6 +484,25 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
461484
)
462485
}
463486

487+
private renderAccessRequestButtons() {
488+
const { t } = this.props
489+
return this.state.accessRequestCallbacks.length > 0 ? (
490+
<div id="prompter-device-access">
491+
{this.state.accessRequestCallbacks.map((accessRequest, i) => (
492+
<button
493+
className="btn btn-secondary"
494+
key={i}
495+
onClick={() => {
496+
accessRequest.callback()
497+
}}
498+
>
499+
{t('Connect to {{deviceName}}', { deviceName: accessRequest.deviceName })}
500+
</button>
501+
))}
502+
</div>
503+
) : null
504+
}
505+
464506
render(): JSX.Element {
465507
const { t } = this.props
466508

@@ -498,6 +540,7 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
498540
}}
499541
></div>
500542
) : null}
543+
{this.renderAccessRequestButtons()}
501544
</>
502545
) : this.props.studio ? (
503546
<StudioScreenSaver studioId={this.props.studio._id} />

packages/webui/src/client/ui/Prompter/controller/manager.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ControllerAbstract } from './lib'
55
import { JoyConController } from './joycon-device'
66
import { KeyboardController } from './keyboard-device'
77
import { ShuttleKeyboardController } from './shuttle-keyboard-device'
8+
import { ShuttleWebHidController } from './shuttle-webhid-device'
89

910
export class PrompterControlManager {
1011
private _view: PrompterViewContent
@@ -35,6 +36,9 @@ export class PrompterControlManager {
3536
if (this._view.configOptions.mode.indexOf(PrompterConfigMode.JOYCON) > -1) {
3637
this._controllers.push(new JoyConController(this._view))
3738
}
39+
if (this._view.configOptions.mode.indexOf(PrompterConfigMode.SHUTTLEWEBHID) > -1) {
40+
this._controllers.push(new ShuttleWebHidController(this._view))
41+
}
3842
}
3943

4044
if (this._controllers.length === 0) {
@@ -43,6 +47,7 @@ export class PrompterControlManager {
4347
this._controllers.push(new KeyboardController(this._view))
4448
}
4549
}
50+
4651
destroy(): void {
4752
window.removeEventListener('keydown', this._onKeyDown)
4853
window.removeEventListener('keyup', this._onKeyUp)
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { ControllerAbstract } from './lib'
2+
import { AccessRequestCallback, PrompterViewInner } from '../PrompterView'
3+
import { logger } from '../../../../lib/logging'
4+
5+
import { getOpenedDevices, requestAccess, setupShuttle, Shuttle } from 'shuttle-webhid'
6+
7+
/**
8+
* This class handles control of the prompter using Contour Shuttle / Multimedia Controller line of devices
9+
*/
10+
export class ShuttleWebHidController extends ControllerAbstract {
11+
private prompterView: PrompterViewInner
12+
13+
private speedMap = [0, 1, 2, 3, 5, 7, 9, 30]
14+
private jogMultiplierMap = this.speedMap.slice(1)
15+
16+
private readonly MIN_JOG_MULTIPLIER = 0
17+
private readonly MAX_JOG_MULTIPLIER = this.jogMultiplierMap.length - 1
18+
private readonly JOG_BASE_MOVEMENT_PX = 20
19+
20+
private updateSpeedHandle: number | null = null
21+
private lastSpeed = 0
22+
private currentPosition = 0
23+
24+
private connectedShuttle: Shuttle | undefined
25+
26+
private jogMultiplierIndex = 3
27+
28+
private accessRequestCallback: AccessRequestCallback = {
29+
callback: this.requestAccess.bind(this),
30+
deviceName: 'Contour Shuttle',
31+
}
32+
33+
constructor(view: PrompterViewInner) {
34+
super()
35+
this.prompterView = view
36+
37+
this.attemptConnectingToKnownDevice()
38+
}
39+
40+
protected static makeSpeedStepMap(speedMap: number[]): number[] {
41+
return [
42+
...speedMap
43+
.slice(1)
44+
.reverse()
45+
.map((i) => i * -1),
46+
...speedMap.slice(),
47+
]
48+
}
49+
50+
public requestAccess(): void {
51+
requestAccess()
52+
.then((devices) => {
53+
if (devices.length === 0) {
54+
logger.error('No device was selected')
55+
return
56+
}
57+
logger.info(`Access granted to "${devices[0].productName}"`)
58+
this.openDevice(devices[0]).catch(logger.error)
59+
})
60+
.catch(logger.error)
61+
}
62+
63+
protected attemptConnectingToKnownDevice(): void {
64+
getOpenedDevices()
65+
.then((devices) => {
66+
if (devices.length > 0) {
67+
logger.info(`"${devices[0].productName}" already granted in a previous session`)
68+
this.openDevice(devices[0]).catch(logger.error)
69+
}
70+
this.prompterView.registerAccessRequestCallback(this.accessRequestCallback)
71+
})
72+
.catch(logger.error)
73+
}
74+
75+
protected async openDevice(device: HIDDevice): Promise<void> {
76+
const shuttle = await setupShuttle(device)
77+
78+
this.prompterView.unregisterAccessRequestCallback(this.accessRequestCallback)
79+
80+
this.connectedShuttle = shuttle
81+
82+
logger.info(`Connected to "${shuttle.info.name}"`)
83+
84+
shuttle.on('error', (error) => {
85+
logger.error(`Error: ${error}`)
86+
})
87+
shuttle.on('disconnected', () => {
88+
logger.warn(`disconnected`)
89+
})
90+
shuttle.on('down', (keyIndex: number) => {
91+
this.onButtonPressed(keyIndex)
92+
logger.debug(`Button ${keyIndex} down`)
93+
})
94+
shuttle.on('up', (keyIndex: number) => {
95+
logger.debug(`Button ${keyIndex} up`)
96+
})
97+
shuttle.on('jog', (delta, value) => {
98+
this.onJog(delta)
99+
logger.debug(`jog ${delta} ${value}`)
100+
})
101+
shuttle.on('shuttle', (value) => {
102+
this.onShuttle(value)
103+
logger.debug(`shuttle ${value}`)
104+
})
105+
}
106+
107+
public destroy(): void {
108+
this.connectedShuttle?.close().catch(logger.error)
109+
// Nothing
110+
}
111+
public onKeyDown(_e: KeyboardEvent): void {
112+
// Nothing
113+
}
114+
public onKeyUp(_e: KeyboardEvent): void {
115+
// Nothing
116+
}
117+
public onMouseKeyDown(_e: MouseEvent): void {
118+
// Nothing
119+
}
120+
public onMouseKeyUp(_e: MouseEvent): void {
121+
// Nothing
122+
}
123+
public onWheel(_e: WheelEvent): void {
124+
// Nothing
125+
}
126+
127+
protected onButtonPressed(keyIndex: number): void {
128+
switch (keyIndex) {
129+
case 0:
130+
this.onJogMultipierDelta(-1)
131+
break
132+
case 1:
133+
this.resetSpeed()
134+
this.prompterView.scrollToPrevious()
135+
break
136+
case 2:
137+
this.resetSpeed()
138+
this.prompterView.scrollToLive()
139+
break
140+
case 3:
141+
this.resetSpeed()
142+
this.prompterView.scrollToFollowing()
143+
break
144+
case 4:
145+
this.onJogMultipierDelta(1)
146+
break
147+
}
148+
}
149+
150+
protected onJogMultipierDelta(delta: number): void {
151+
this.jogMultiplierIndex = Math.min(
152+
Math.max(this.jogMultiplierIndex + delta, this.MIN_JOG_MULTIPLIER),
153+
this.MAX_JOG_MULTIPLIER
154+
)
155+
}
156+
157+
protected onJog(delta: number): void {
158+
if (Math.abs(delta) > 1) return // this is a hack because sometimes, right after connecting to the device, the delta would be larger than 1 or -1
159+
160+
this.resetSpeed()
161+
window.scrollBy(0, this.JOG_BASE_MOVEMENT_PX * delta * this.jogMultiplierMap[this.jogMultiplierIndex])
162+
}
163+
164+
protected onShuttle(value: number): void {
165+
this.lastSpeed = this.speedMap[Math.abs(value)] * Math.sign(value)
166+
this.updateScrollPosition()
167+
}
168+
169+
protected resetSpeed(): void {
170+
this.lastSpeed = 0
171+
}
172+
173+
private updateScrollPosition() {
174+
if (this.updateSpeedHandle !== null) return
175+
176+
if (this.lastSpeed !== 0) {
177+
window.scrollBy(0, this.lastSpeed)
178+
179+
const scrollPosition = window.scrollY
180+
// check for reached end-of-scroll:
181+
if (this.currentPosition !== undefined && scrollPosition !== undefined) {
182+
if (this.currentPosition === scrollPosition) {
183+
// We tried to move, but haven't
184+
this.resetSpeed()
185+
}
186+
this.currentPosition = scrollPosition
187+
}
188+
}
189+
190+
this.updateSpeedHandle = window.requestAnimationFrame(() => {
191+
this.updateSpeedHandle = null
192+
this.updateScrollPosition()
193+
})
194+
}
195+
}

packages/yarn.lock

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4942,6 +4942,15 @@ __metadata:
49424942
languageName: node
49434943
linkType: hard
49444944

4945+
"@shuttle-lib/core@npm:0.0.2":
4946+
version: 0.0.2
4947+
resolution: "@shuttle-lib/core@npm:0.0.2"
4948+
dependencies:
4949+
tslib: ^2.4.0
4950+
checksum: edbb825940fee2d5fc22fb4c8c44607f6f75197ade72204876356153a6274045efcea8f9cc6ab6a6bec08da1a65e87ea4b9e15678bbfb9721b72aa4104d29598
4951+
languageName: node
4952+
linkType: hard
4953+
49454954
"@sideway/address@npm:^4.1.5":
49464955
version: 4.1.5
49474956
resolution: "@sideway/address@npm:4.1.5"
@@ -5369,6 +5378,7 @@ __metadata:
53695378
sass: ^1.77.8
53705379
semver: ^7.6.3
53715380
sha.js: ^2.4.11
5381+
shuttle-webhid: ^0.0.2
53725382
sinon: ^14.0.2
53735383
type-fest: ^3.13.1
53745384
typescript: ^5.2.2
@@ -6870,6 +6880,13 @@ __metadata:
68706880
languageName: node
68716881
linkType: hard
68726882

6883+
"@types/w3c-web-hid@npm:^1.0.3":
6884+
version: 1.0.6
6885+
resolution: "@types/w3c-web-hid@npm:1.0.6"
6886+
checksum: 14773befa9c458b3459cdb530a8269937e623e6b72c6bd2d7f88b42f8d47c02d8a64ddc98f79c81c930b6eadf1dc1c94917b553ead72acc13c8406f65310c85d
6887+
languageName: node
6888+
linkType: hard
6889+
68736890
"@types/webidl-conversions@npm:*":
68746891
version: 7.0.0
68756892
resolution: "@types/webidl-conversions@npm:7.0.0"
@@ -24824,6 +24841,18 @@ asn1@evs-broadcast/node-asn1:
2482424841
languageName: node
2482524842
linkType: hard
2482624843

24844+
"shuttle-webhid@npm:^0.0.2":
24845+
version: 0.0.2
24846+
resolution: "shuttle-webhid@npm:0.0.2"
24847+
dependencies:
24848+
"@shuttle-lib/core": 0.0.2
24849+
"@types/w3c-web-hid": ^1.0.3
24850+
buffer: ^6.0.3
24851+
p-queue: ^6.6.2
24852+
checksum: 87db16e6dc7e942a79caf816844382ae2e367735494ce753bd2d75009b8fd6e1d736858a53ec66983e35b2ea63579b32b381f33d25cb8989af002870447d8392
24853+
languageName: node
24854+
linkType: hard
24855+
2482724856
"side-channel@npm:^1.0.4, side-channel@npm:^1.0.6":
2482824857
version: 1.0.6
2482924858
resolution: "side-channel@npm:1.0.6"

0 commit comments

Comments
 (0)