Skip to content

Commit 063021b

Browse files
authored
Merge pull request #1372 from tv2norge-collab/contribute/EAV-297
feat: add Shuttle WebHID prompter controller support
2 parents 1b31e45 + 1c17e72 commit 063021b

File tree

8 files changed

+295
-2
lines changed

8 files changed

+295
-2
lines changed

packages/documentation/docs/user-guide/features/prompter.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ The prompter can be controlled by different types of controllers. The control mo
4343
| `?mode=mouse` | Controlled by mouse only. [See configuration details](prompter.md#control-using-mouse-scroll-wheel) |
4444
| `?mode=keyboard` | Controlled by keyboard only. [See configuration details](prompter.md#control-using-keyboard) |
4545
| `?mode=shuttlekeyboard` | Controlled by a Contour Design ShuttleXpress, X-keys Jog and Shuttle or any compatible, configured as keyboard-ish device. [See configuration details](prompter.md#control-using-contour-shuttlexpress-or-x-keys) |
46+
| `?mode=shuttlewebhid` | Controlled by a Contour Design ShuttleXpress, using the browser's WebHID API [See configuration details](prompter.md#control-using-contour-shuttlexpress-via-webhid) |
4647
| `?mode=pedal` | Controlled by any MIDI device outputting note values between 0 - 127 of CC notes on channel 8. Analogue Expression pedals work well with TRS-USB midi-converters. [See configuration details](prompter.md#control-using-midi-input-mode-pedal) |
4748
| `?mode=joycon` | Controlled by Nintendo Switch Joycon, using the HTML5 GamePad API. [See configuration details](prompter.md#control-using-nintendo-joycon-gamepad) |
4849

@@ -94,6 +95,14 @@ Configuration files that can be used in their respective driver software:
9495
- [Contour ShuttleXpress](https://github.com/nrkno/sofie-core/blob/release26/resources/prompter_layout_shuttlexpress.pref)
9596
- [X-keys](https://github.com/nrkno/sofie-core/blob/release26/resources/prompter_layout_xkeys.mw3)
9697

98+
#### Control using Contour ShuttleXpress via WebHID
99+
100+
This mode uses a Contour ShuttleXpress (Multimedia Controller Xpress) through web browser's WebHID API.
101+
102+
When opening the Prompter View for the first time, it is necessary to press the _Connect to Contour Shuttle_ button in the top left corner of the screen, select the device, and press _Connect_.
103+
104+
![Contour ShuttleXpress input mapping](/img/docs/main/features/contour-shuttle-webhid.jpg)
105+
97106
####
98107

99108
#### Control using midi input \(_?mode=pedal_\)
41.7 KB
Loading

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: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import { StudioScreenSaver } from '../StudioScreenSaver/StudioScreenSaver'
3333
import { PrompterControlManager } from './controller/manager'
3434
import { OverUnderTimer } from './OverUnderTimer'
3535
import { PrompterAPI, PrompterData, PrompterDataPart } from './prompter'
36+
import { doUserAction, UserAction } from '../../lib/clientUserAction'
37+
import { MeteorCall } from '../../lib/meteorApi'
3638

3739
const DEFAULT_UPDATE_THROTTLE = 250 //ms
3840
const PIECE_MISSING_UPDATE_THROTTLE = 2000 //ms
@@ -74,6 +76,7 @@ export enum PrompterConfigMode {
7476
SHUTTLEKEYBOARD = 'shuttlekeyboard',
7577
JOYCON = 'joycon',
7678
PEDAL = 'pedal',
79+
SHUTTLEWEBHID = 'shuttlewebhid',
7780
}
7881

7982
export interface IPrompterControllerState {
@@ -92,6 +95,15 @@ interface ITrackedProps {
9295
subsReady: boolean
9396
}
9497

98+
export interface AccessRequestCallback {
99+
deviceName: string
100+
callback: () => void
101+
}
102+
103+
interface IState {
104+
accessRequestCallbacks: AccessRequestCallback[]
105+
}
106+
95107
function asArray<T>(value: T | T[] | null): T[] {
96108
if (Array.isArray(value)) {
97109
return value
@@ -102,7 +114,7 @@ function asArray<T>(value: T | T[] | null): T[] {
102114
}
103115
}
104116

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

108120
configOptions: PrompterConfig
@@ -115,7 +127,7 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
115127
constructor(props: Translated<IProps & ITrackedProps>) {
116128
super(props)
117129
this.state = {
118-
subsReady: false,
130+
accessRequestCallbacks: [],
119131
}
120132
// Disable the context menu:
121133
document.addEventListener('contextmenu', (e) => {
@@ -287,6 +299,19 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
287299
// margin in pixels
288300
return ((this.configOptions.margin || 0) * window.innerHeight) / 100
289301
}
302+
303+
public registerAccessRequestCallback(callback: AccessRequestCallback): void {
304+
this.setState((state) => ({
305+
accessRequestCallbacks: [...state.accessRequestCallbacks, callback],
306+
}))
307+
}
308+
309+
public unregisterAccessRequestCallback(callback: AccessRequestCallback): void {
310+
this.setState((state) => ({
311+
accessRequestCallbacks: state.accessRequestCallbacks.filter((candidate) => candidate !== callback),
312+
}))
313+
}
314+
290315
scrollToPartInstance(partInstanceId: PartInstanceId): void {
291316
const scrollMargin = this.calculateScrollPosition()
292317
const target = document.querySelector<HTMLElement>(`[data-part-instance-id="${partInstanceId}"]`)
@@ -361,6 +386,17 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
361386
findAnchorPosition(startY: number, endY: number, sortDirection = 1): number | null {
362387
return (this.listAnchorPositions(startY, endY, sortDirection)[0] || [])[0] || null
363388
}
389+
take(e: Event | string): void {
390+
const { t } = this.props
391+
if (!this.props.rundownPlaylist) {
392+
logger.error('No active Rundown Playlist to perform a Take in')
393+
return
394+
}
395+
const playlist = this.props.rundownPlaylist
396+
doUserAction(t, e, UserAction.TAKE, (e, ts) =>
397+
MeteorCall.userAction.take(e, ts, playlist._id, playlist.currentPartInfo?.partInstanceId ?? null)
398+
)
399+
}
364400
private onWindowScroll = () => {
365401
this.triggerCheckCurrentTakeMarkers()
366402
}
@@ -461,6 +497,25 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
461497
)
462498
}
463499

500+
private renderAccessRequestButtons() {
501+
const { t } = this.props
502+
return this.state.accessRequestCallbacks.length > 0 ? (
503+
<div id="prompter-device-access">
504+
{this.state.accessRequestCallbacks.map((accessRequest, i) => (
505+
<button
506+
className="btn btn-secondary"
507+
key={i}
508+
onClick={() => {
509+
accessRequest.callback()
510+
}}
511+
>
512+
{t('Connect to {{deviceName}}', { deviceName: accessRequest.deviceName })}
513+
</button>
514+
))}
515+
</div>
516+
) : null
517+
}
518+
464519
render(): JSX.Element {
465520
const { t } = this.props
466521

@@ -498,6 +553,7 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
498553
}}
499554
></div>
500555
) : null}
556+
{this.renderAccessRequestButtons()}
501557
</>
502558
) : this.props.studio ? (
503559
<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: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { ControllerAbstract } from './lib'
2+
import { AccessRequestCallback, PrompterViewContent } from '../PrompterView'
3+
4+
import { getOpenedDevices, requestAccess, setupShuttle, Shuttle } from 'shuttle-webhid'
5+
import { logger } from '../../../lib/logging'
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: PrompterViewContent
12+
13+
private speedMap = [0, 1, 2, 3, 5, 7, 9, 30]
14+
15+
private readonly JOG_BASE_MOVEMENT_PX = 100
16+
17+
private updateSpeedHandle: number | null = null
18+
private lastSpeed = 0
19+
private currentPosition = 0
20+
21+
private connectedShuttle: Shuttle | undefined
22+
23+
private accessRequestCallback: AccessRequestCallback = {
24+
callback: this.requestAccess.bind(this),
25+
deviceName: 'Contour Shuttle',
26+
}
27+
28+
constructor(view: PrompterViewContent) {
29+
super()
30+
this.prompterView = view
31+
32+
this.attemptConnectingToKnownDevice()
33+
}
34+
35+
protected static makeSpeedStepMap(speedMap: number[]): number[] {
36+
return [
37+
...speedMap
38+
.slice(1)
39+
.reverse()
40+
.map((i) => i * -1),
41+
...speedMap.slice(),
42+
]
43+
}
44+
45+
public requestAccess(): void {
46+
requestAccess()
47+
.then((devices) => {
48+
if (devices.length === 0) {
49+
logger.error('No device was selected')
50+
return
51+
}
52+
logger.info(`Access granted to "${devices[0].productName}"`)
53+
this.openDevice(devices[0]).catch(logger.error)
54+
})
55+
.catch(logger.error)
56+
}
57+
58+
protected attemptConnectingToKnownDevice(): void {
59+
getOpenedDevices()
60+
.then((devices) => {
61+
if (devices.length > 0) {
62+
logger.info(`"${devices[0].productName}" already granted in a previous session`)
63+
this.openDevice(devices[0]).catch(logger.error)
64+
}
65+
this.prompterView.registerAccessRequestCallback(this.accessRequestCallback)
66+
})
67+
.catch(logger.error)
68+
}
69+
70+
protected async openDevice(device: HIDDevice): Promise<void> {
71+
const shuttle = await setupShuttle(device)
72+
73+
this.prompterView.unregisterAccessRequestCallback(this.accessRequestCallback)
74+
75+
this.connectedShuttle = shuttle
76+
77+
logger.info(`Connected to "${shuttle.info.name}"`)
78+
79+
shuttle.on('error', (error) => {
80+
logger.error(`Error: ${error}`)
81+
})
82+
shuttle.on('disconnected', () => {
83+
logger.warn(`disconnected`)
84+
})
85+
shuttle.on('down', (keyIndex: number) => {
86+
this.onButtonPressed(keyIndex)
87+
logger.debug(`Button ${keyIndex} down`)
88+
})
89+
shuttle.on('up', (keyIndex: number) => {
90+
logger.debug(`Button ${keyIndex} up`)
91+
})
92+
shuttle.on('jog', (delta, value) => {
93+
this.onJog(delta)
94+
logger.debug(`jog ${delta} ${value}`)
95+
})
96+
shuttle.on('shuttle', (value) => {
97+
this.onShuttle(value)
98+
logger.debug(`shuttle ${value}`)
99+
})
100+
}
101+
102+
public destroy(): void {
103+
this.connectedShuttle?.close().catch(logger.error)
104+
// Nothing
105+
}
106+
public onKeyDown(_e: KeyboardEvent): void {
107+
// Nothing
108+
}
109+
public onKeyUp(_e: KeyboardEvent): void {
110+
// Nothing
111+
}
112+
public onMouseKeyDown(_e: MouseEvent): void {
113+
// Nothing
114+
}
115+
public onMouseKeyUp(_e: MouseEvent): void {
116+
// Nothing
117+
}
118+
public onWheel(_e: WheelEvent): void {
119+
// Nothing
120+
}
121+
122+
protected onButtonPressed(keyIndex: number): void {
123+
switch (keyIndex) {
124+
case 0:
125+
// no-op
126+
break
127+
case 1:
128+
this.resetSpeed()
129+
this.prompterView.scrollToPrevious()
130+
break
131+
case 2:
132+
this.resetSpeed()
133+
this.prompterView.scrollToLive()
134+
break
135+
case 3:
136+
this.resetSpeed()
137+
this.prompterView.scrollToFollowing()
138+
break
139+
case 4:
140+
this.prompterView.take('Shuttle button 4 press')
141+
break
142+
}
143+
}
144+
145+
protected onJog(delta: number): void {
146+
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
147+
148+
this.resetSpeed()
149+
window.scrollBy(0, this.JOG_BASE_MOVEMENT_PX * delta)
150+
}
151+
152+
protected onShuttle(value: number): void {
153+
this.lastSpeed = this.speedMap[Math.abs(value)] * Math.sign(value)
154+
this.updateScrollPosition()
155+
}
156+
157+
protected resetSpeed(): void {
158+
this.lastSpeed = 0
159+
}
160+
161+
private updateScrollPosition() {
162+
if (this.updateSpeedHandle !== null) return
163+
164+
if (this.lastSpeed !== 0) {
165+
window.scrollBy(0, this.lastSpeed)
166+
167+
const scrollPosition = window.scrollY
168+
// check for reached end-of-scroll:
169+
if (this.currentPosition !== undefined && scrollPosition !== undefined) {
170+
if (this.currentPosition === scrollPosition) {
171+
// We tried to move, but haven't
172+
this.resetSpeed()
173+
}
174+
this.currentPosition = scrollPosition
175+
}
176+
}
177+
178+
this.updateSpeedHandle = window.requestAnimationFrame(() => {
179+
this.updateSpeedHandle = null
180+
this.updateScrollPosition()
181+
})
182+
}
183+
}

0 commit comments

Comments
 (0)