Skip to content

Commit

Permalink
Merge pull request #1372 from tv2norge-collab/contribute/EAV-297
Browse files Browse the repository at this point in the history
feat: add Shuttle WebHID prompter controller support
  • Loading branch information
nytamin authored Feb 3, 2025
2 parents 1b31e45 + 1c17e72 commit 063021b
Show file tree
Hide file tree
Showing 8 changed files with 295 additions and 2 deletions.
9 changes: 9 additions & 0 deletions packages/documentation/docs/user-guide/features/prompter.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ The prompter can be controlled by different types of controllers. The control mo
| `?mode=mouse` | Controlled by mouse only. [See configuration details](prompter.md#control-using-mouse-scroll-wheel) |
| `?mode=keyboard` | Controlled by keyboard only. [See configuration details](prompter.md#control-using-keyboard) |
| `?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) |
| `?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) |
| `?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) |
| `?mode=joycon` | Controlled by Nintendo Switch Joycon, using the HTML5 GamePad API. [See configuration details](prompter.md#control-using-nintendo-joycon-gamepad) |

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

#### Control using Contour ShuttleXpress via WebHID

This mode uses a Contour ShuttleXpress (Multimedia Controller Xpress) through web browser's WebHID API.

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_.

![Contour ShuttleXpress input mapping](/img/docs/main/features/contour-shuttle-webhid.jpg)

####

#### Control using midi input \(_?mode=pedal_\)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/webui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"react-timer-hoc": "^2.3.0",
"semver": "^7.6.3",
"sha.js": "^2.4.11",
"shuttle-webhid": "^0.0.2",
"type-fest": "^3.13.1",
"underscore": "^1.13.7",
"velocity-animate": "^1.5.2",
Expand Down
10 changes: 10 additions & 0 deletions packages/webui/src/client/styles/prompter.scss
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,16 @@ body.prompter-scrollbar {
}
}

#prompter-device-access {
position: fixed;
top: 0;
left: 0;
padding: 0.7em;
button {
background: black;
}
}

.prompter-timing-clock {
position: fixed;
display: block;
Expand Down
60 changes: 58 additions & 2 deletions packages/webui/src/client/ui/Prompter/PrompterView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import { StudioScreenSaver } from '../StudioScreenSaver/StudioScreenSaver'
import { PrompterControlManager } from './controller/manager'
import { OverUnderTimer } from './OverUnderTimer'
import { PrompterAPI, PrompterData, PrompterDataPart } from './prompter'
import { doUserAction, UserAction } from '../../lib/clientUserAction'
import { MeteorCall } from '../../lib/meteorApi'

const DEFAULT_UPDATE_THROTTLE = 250 //ms
const PIECE_MISSING_UPDATE_THROTTLE = 2000 //ms
Expand Down Expand Up @@ -74,6 +76,7 @@ export enum PrompterConfigMode {
SHUTTLEKEYBOARD = 'shuttlekeyboard',
JOYCON = 'joycon',
PEDAL = 'pedal',
SHUTTLEWEBHID = 'shuttlewebhid',
}

export interface IPrompterControllerState {
Expand All @@ -92,6 +95,15 @@ interface ITrackedProps {
subsReady: boolean
}

export interface AccessRequestCallback {
deviceName: string
callback: () => void
}

interface IState {
accessRequestCallbacks: AccessRequestCallback[]
}

function asArray<T>(value: T | T[] | null): T[] {
if (Array.isArray(value)) {
return value
Expand All @@ -102,7 +114,7 @@ function asArray<T>(value: T | T[] | null): T[] {
}
}

export class PrompterViewContent extends React.Component<Translated<IProps & ITrackedProps>> {
export class PrompterViewContent extends React.Component<Translated<IProps & ITrackedProps>, IState> {
autoScrollPreviousPartInstanceId: PartInstanceId | null = null

configOptions: PrompterConfig
Expand All @@ -115,7 +127,7 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
constructor(props: Translated<IProps & ITrackedProps>) {
super(props)
this.state = {
subsReady: false,
accessRequestCallbacks: [],
}
// Disable the context menu:
document.addEventListener('contextmenu', (e) => {
Expand Down Expand Up @@ -287,6 +299,19 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
// margin in pixels
return ((this.configOptions.margin || 0) * window.innerHeight) / 100
}

public registerAccessRequestCallback(callback: AccessRequestCallback): void {
this.setState((state) => ({
accessRequestCallbacks: [...state.accessRequestCallbacks, callback],
}))
}

public unregisterAccessRequestCallback(callback: AccessRequestCallback): void {
this.setState((state) => ({
accessRequestCallbacks: state.accessRequestCallbacks.filter((candidate) => candidate !== callback),
}))
}

scrollToPartInstance(partInstanceId: PartInstanceId): void {
const scrollMargin = this.calculateScrollPosition()
const target = document.querySelector<HTMLElement>(`[data-part-instance-id="${partInstanceId}"]`)
Expand Down Expand Up @@ -361,6 +386,17 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
findAnchorPosition(startY: number, endY: number, sortDirection = 1): number | null {
return (this.listAnchorPositions(startY, endY, sortDirection)[0] || [])[0] || null
}
take(e: Event | string): void {
const { t } = this.props
if (!this.props.rundownPlaylist) {
logger.error('No active Rundown Playlist to perform a Take in')
return
}
const playlist = this.props.rundownPlaylist
doUserAction(t, e, UserAction.TAKE, (e, ts) =>
MeteorCall.userAction.take(e, ts, playlist._id, playlist.currentPartInfo?.partInstanceId ?? null)
)
}
private onWindowScroll = () => {
this.triggerCheckCurrentTakeMarkers()
}
Expand Down Expand Up @@ -461,6 +497,25 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
)
}

private renderAccessRequestButtons() {
const { t } = this.props
return this.state.accessRequestCallbacks.length > 0 ? (
<div id="prompter-device-access">
{this.state.accessRequestCallbacks.map((accessRequest, i) => (
<button
className="btn btn-secondary"
key={i}
onClick={() => {
accessRequest.callback()
}}
>
{t('Connect to {{deviceName}}', { deviceName: accessRequest.deviceName })}
</button>
))}
</div>
) : null
}

render(): JSX.Element {
const { t } = this.props

Expand Down Expand Up @@ -498,6 +553,7 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
}}
></div>
) : null}
{this.renderAccessRequestButtons()}
</>
) : this.props.studio ? (
<StudioScreenSaver studioId={this.props.studio._id} />
Expand Down
5 changes: 5 additions & 0 deletions packages/webui/src/client/ui/Prompter/controller/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ControllerAbstract } from './lib'
import { JoyConController } from './joycon-device'
import { KeyboardController } from './keyboard-device'
import { ShuttleKeyboardController } from './shuttle-keyboard-device'
import { ShuttleWebHidController } from './shuttle-webhid-device'

export class PrompterControlManager {
private _view: PrompterViewContent
Expand Down Expand Up @@ -35,6 +36,9 @@ export class PrompterControlManager {
if (this._view.configOptions.mode.indexOf(PrompterConfigMode.JOYCON) > -1) {
this._controllers.push(new JoyConController(this._view))
}
if (this._view.configOptions.mode.indexOf(PrompterConfigMode.SHUTTLEWEBHID) > -1) {
this._controllers.push(new ShuttleWebHidController(this._view))
}
}

if (this._controllers.length === 0) {
Expand All @@ -43,6 +47,7 @@ export class PrompterControlManager {
this._controllers.push(new KeyboardController(this._view))
}
}

destroy(): void {
window.removeEventListener('keydown', this._onKeyDown)
window.removeEventListener('keyup', this._onKeyUp)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { ControllerAbstract } from './lib'
import { AccessRequestCallback, PrompterViewContent } from '../PrompterView'

import { getOpenedDevices, requestAccess, setupShuttle, Shuttle } from 'shuttle-webhid'
import { logger } from '../../../lib/logging'

/**
* This class handles control of the prompter using Contour Shuttle / Multimedia Controller line of devices
*/
export class ShuttleWebHidController extends ControllerAbstract {
private prompterView: PrompterViewContent

private speedMap = [0, 1, 2, 3, 5, 7, 9, 30]

private readonly JOG_BASE_MOVEMENT_PX = 100

private updateSpeedHandle: number | null = null
private lastSpeed = 0
private currentPosition = 0

private connectedShuttle: Shuttle | undefined

private accessRequestCallback: AccessRequestCallback = {
callback: this.requestAccess.bind(this),
deviceName: 'Contour Shuttle',
}

constructor(view: PrompterViewContent) {
super()
this.prompterView = view

this.attemptConnectingToKnownDevice()
}

protected static makeSpeedStepMap(speedMap: number[]): number[] {
return [
...speedMap
.slice(1)
.reverse()
.map((i) => i * -1),
...speedMap.slice(),
]
}

public requestAccess(): void {
requestAccess()
.then((devices) => {
if (devices.length === 0) {
logger.error('No device was selected')
return
}
logger.info(`Access granted to "${devices[0].productName}"`)
this.openDevice(devices[0]).catch(logger.error)
})
.catch(logger.error)
}

protected attemptConnectingToKnownDevice(): void {
getOpenedDevices()
.then((devices) => {
if (devices.length > 0) {
logger.info(`"${devices[0].productName}" already granted in a previous session`)
this.openDevice(devices[0]).catch(logger.error)
}
this.prompterView.registerAccessRequestCallback(this.accessRequestCallback)
})
.catch(logger.error)
}

protected async openDevice(device: HIDDevice): Promise<void> {
const shuttle = await setupShuttle(device)

this.prompterView.unregisterAccessRequestCallback(this.accessRequestCallback)

this.connectedShuttle = shuttle

logger.info(`Connected to "${shuttle.info.name}"`)

shuttle.on('error', (error) => {
logger.error(`Error: ${error}`)
})
shuttle.on('disconnected', () => {
logger.warn(`disconnected`)
})
shuttle.on('down', (keyIndex: number) => {
this.onButtonPressed(keyIndex)
logger.debug(`Button ${keyIndex} down`)
})
shuttle.on('up', (keyIndex: number) => {
logger.debug(`Button ${keyIndex} up`)
})
shuttle.on('jog', (delta, value) => {
this.onJog(delta)
logger.debug(`jog ${delta} ${value}`)
})
shuttle.on('shuttle', (value) => {
this.onShuttle(value)
logger.debug(`shuttle ${value}`)
})
}

public destroy(): void {
this.connectedShuttle?.close().catch(logger.error)
// Nothing
}
public onKeyDown(_e: KeyboardEvent): void {
// Nothing
}
public onKeyUp(_e: KeyboardEvent): void {
// Nothing
}
public onMouseKeyDown(_e: MouseEvent): void {
// Nothing
}
public onMouseKeyUp(_e: MouseEvent): void {
// Nothing
}
public onWheel(_e: WheelEvent): void {
// Nothing
}

protected onButtonPressed(keyIndex: number): void {
switch (keyIndex) {
case 0:
// no-op
break
case 1:
this.resetSpeed()
this.prompterView.scrollToPrevious()
break
case 2:
this.resetSpeed()
this.prompterView.scrollToLive()
break
case 3:
this.resetSpeed()
this.prompterView.scrollToFollowing()
break
case 4:
this.prompterView.take('Shuttle button 4 press')
break
}
}

protected onJog(delta: number): void {
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

this.resetSpeed()
window.scrollBy(0, this.JOG_BASE_MOVEMENT_PX * delta)
}

protected onShuttle(value: number): void {
this.lastSpeed = this.speedMap[Math.abs(value)] * Math.sign(value)
this.updateScrollPosition()
}

protected resetSpeed(): void {
this.lastSpeed = 0
}

private updateScrollPosition() {
if (this.updateSpeedHandle !== null) return

if (this.lastSpeed !== 0) {
window.scrollBy(0, this.lastSpeed)

const scrollPosition = window.scrollY
// check for reached end-of-scroll:
if (this.currentPosition !== undefined && scrollPosition !== undefined) {
if (this.currentPosition === scrollPosition) {
// We tried to move, but haven't
this.resetSpeed()
}
this.currentPosition = scrollPosition
}
}

this.updateSpeedHandle = window.requestAnimationFrame(() => {
this.updateSpeedHandle = null
this.updateScrollPosition()
})
}
}
Loading

0 comments on commit 063021b

Please sign in to comment.