Skip to content

Commit 22916fd

Browse files
authored
feat(PrompterView): customizable shuttle webhid buttons (Sofie-Automation#1610)
* feat(EAV-372): customizable shuttle webhid buttons an array of keyIndex:actionId can be provided in shuttleWebHid_buttonMap * feat(EAV-372): execute actions also on shuttle button release * chore(EAV-372): improve log message
1 parent 9502cc5 commit 22916fd

9 files changed

Lines changed: 104 additions & 25 deletions

File tree

meteor/server/api/userActions.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -399,9 +399,9 @@ class ServerUserActionAPI
399399
userEvent: string,
400400
eventTime: Time,
401401
rundownPlaylistId: RundownPlaylistId,
402-
actionDocId: AdLibActionId | RundownBaselineAdLibActionId | BucketAdLibActionId,
402+
actionDocId: AdLibActionId | RundownBaselineAdLibActionId | BucketAdLibActionId | null,
403403
actionId: string,
404-
userData: ActionUserData,
404+
userData: ActionUserData | null,
405405
triggerMode: string | null
406406
) {
407407
return ServerClientAPI.runUserActionInLogForPlaylistOnWorker(
@@ -411,7 +411,7 @@ class ServerUserActionAPI
411411
rundownPlaylistId,
412412
() => {
413413
check(rundownPlaylistId, String)
414-
check(actionDocId, String)
414+
check(actionDocId, Match.Maybe(String))
415415
check(actionId, String)
416416
check(userData, Match.Any)
417417
check(triggerMode, Match.Maybe(String))

packages/corelib/src/worker/studio.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,9 +266,9 @@ export interface QueueNextSegmentProps extends RundownPlayoutPropsBase {
266266
}
267267
export type QueueNextSegmentResult = { nextPartId: PartId } | { queuedSegmentId: SegmentId | null }
268268
export interface ExecuteActionProps extends RundownPlayoutPropsBase {
269-
actionDocId: AdLibActionId | RundownBaselineAdLibActionId | BucketAdLibActionId
269+
actionDocId: AdLibActionId | RundownBaselineAdLibActionId | BucketAdLibActionId | null
270270
actionId: string
271-
userData: any
271+
userData: any | null
272272
triggerMode?: string
273273
actionOptions?: { [key: string]: any }
274274
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,21 @@ When opening the Prompter View for the first time, it is necessary to press the
104104

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

107+
**Customizable Button Mapping:**
108+
109+
By default, the ShuttleXpress buttons execute built-in prompter actions. However, you can customize button behavior by mapping buttons to global adlib actions using the `shuttleWebHid_buttonMap` query parameter.
110+
111+
| Query parameter | Type | Description |
112+
| :--- | :--- | :--- |
113+
| `shuttleWebHid_buttonMap` | Comma-separated strings | Maps ShuttleXpress buttons to global adlib actions. Each entry should be in the format `buttonIndex:actionId`, where `buttonIndex` is the button number (0-indexed) and `actionId` is the ID of a global adlib action defined in your blueprints. Each custom action is triggered once on button press (trigger mode: `pressed`) and once on button release (trigger mode: `released`). Multiple mappings are comma-separated. |
114+
115+
**Example:**
116+
```
117+
?mode=shuttlewebhid&shuttleWebHid_buttonMap=0:toggle_control_room_mics,1:make_coffee
118+
```
119+
120+
Buttons without a custom mapping will use their default behavior.
121+
107122
####
108123

109124
#### Control using midi input \(_?mode=pedal_\)

packages/job-worker/src/playout/adlibAction.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -76,21 +76,7 @@ export async function executeAdlibActionAndSaveModel(
7676
throw UserError.create(UserErrorMessage.ActionsNotSupported)
7777
}
7878

79-
const [adLibAction, baselineAdLibAction, bucketAdLibAction] = await Promise.all([
80-
context.directCollections.AdLibActions.findOne(data.actionDocId as AdLibActionId, {
81-
projection: { _id: 1, privateData: 1, publicData: 1 },
82-
}),
83-
context.directCollections.RundownBaselineAdLibActions.findOne(
84-
data.actionDocId as RundownBaselineAdLibActionId,
85-
{
86-
projection: { _id: 1, privateData: 1, publicData: 1 },
87-
}
88-
),
89-
context.directCollections.BucketAdLibActions.findOne(data.actionDocId as BucketAdLibActionId, {
90-
projection: { _id: 1, privateData: 1, publicData: 1 },
91-
}),
92-
])
93-
const adLibActionDoc = adLibAction ?? baselineAdLibAction ?? bucketAdLibAction
79+
const adLibActionDoc = await findActionDoc(context, data)
9480

9581
if (adLibActionDoc && adLibActionDoc.invalid)
9682
throw UserError.from(
@@ -202,6 +188,26 @@ export interface ExecuteActionParameters {
202188
triggerMode: string | undefined
203189
}
204190

191+
async function findActionDoc(context: JobContext, data: ExecuteActionProps) {
192+
if (data.actionDocId === null) return undefined
193+
194+
const [adLibAction, baselineAdLibAction, bucketAdLibAction] = await Promise.all([
195+
context.directCollections.AdLibActions.findOne(data.actionDocId as AdLibActionId, {
196+
projection: { _id: 1, privateData: 1, publicData: 1 },
197+
}),
198+
context.directCollections.RundownBaselineAdLibActions.findOne(
199+
data.actionDocId as RundownBaselineAdLibActionId,
200+
{
201+
projection: { _id: 1, privateData: 1, publicData: 1 },
202+
}
203+
),
204+
context.directCollections.BucketAdLibActions.findOne(data.actionDocId as BucketAdLibActionId, {
205+
projection: { _id: 1, privateData: 1, publicData: 1 },
206+
}),
207+
])
208+
return adLibAction ?? baselineAdLibAction ?? bucketAdLibAction
209+
}
210+
205211
export async function executeActionInner(
206212
context: JobContext,
207213
playoutModel: PlayoutModel,

packages/meteor-lib/src/api/userActions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,9 @@ export interface NewUserActionAPI {
121121
userEvent: string,
122122
eventTime: Time,
123123
rundownPlaylistId: RundownPlaylistId,
124-
actionDocId: AdLibActionId | RundownBaselineAdLibActionId | BucketAdLibActionId,
124+
actionDocId: AdLibActionId | RundownBaselineAdLibActionId | BucketAdLibActionId | null,
125125
actionId: string,
126-
userData: ActionUserData,
126+
userData: ActionUserData | null,
127127
triggerMode?: string
128128
): Promise<ClientAPI.ClientResponse<ExecuteActionResult>>
129129
segmentAdLibPieceStart(

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ interface PrompterConfig {
6666
xbox_speedMap?: number[]
6767
xbox_reverseSpeedMap?: number[]
6868
xbox_triggerDeadZone?: number
69+
shuttleWebHid_buttonMap?: string[]
6970
marker?: 'center' | 'top' | 'bottom' | 'hide'
7071
showMarker: boolean
7172
showScroll: boolean
@@ -192,6 +193,7 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
192193
const val = Number.parseFloat(firstIfArray(queryParams['xbox_triggerDeadZone']) as string)
193194
return Number.isNaN(val) ? undefined : val
194195
})(),
196+
shuttleWebHid_buttonMap: asArray(queryParams['shuttleWebHid_buttonMap']),
195197
marker: (firstIfArray(queryParams['marker']) as any) || undefined,
196198
showMarker: queryParams['showmarker'] === undefined ? true : queryParams['showmarker'] === '1',
197199
showScroll: queryParams['showscroll'] === undefined ? true : queryParams['showscroll'] === '1',
@@ -422,6 +424,19 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
422424
MeteorCall.userAction.take(e, ts, playlist._id, playlist.currentPartInfo?.partInstanceId ?? null)
423425
)
424426
}
427+
428+
executeAction(e: Event | string, actionId: string, triggerMode?: string): void {
429+
const { t } = this.props
430+
if (!this.props.rundownPlaylist) {
431+
logger.error('No active Rundown Playlist to execute the action in')
432+
return
433+
}
434+
const playlist = this.props.rundownPlaylist
435+
doUserAction(t, e, UserAction.START_GLOBAL_ADLIB, (e, ts) =>
436+
MeteorCall.userAction.executeAction(e, ts, playlist._id, null, actionId, null, triggerMode)
437+
)
438+
}
439+
425440
private onWindowScroll = () => {
426441
this.triggerCheckCurrentTakeMarkers()
427442
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { PrompterViewContent } from '../PrompterView'
2+
import { ShuttleWebHidController } from './shuttle-webhid-device'
3+
4+
enum ShuttleButtonTriggerMode {
5+
PRESSED = 'pressed',
6+
RELEASED = 'released',
7+
}
8+
9+
export class CustomizableShuttleWebHidController extends ShuttleWebHidController {
10+
private actionMap: Record<number, string> = {}
11+
12+
constructor(view: PrompterViewContent) {
13+
super(view)
14+
view.configOptions.shuttleWebHid_buttonMap?.forEach((entry) => {
15+
const substrings = entry.split(':')
16+
if (substrings.length !== 2) return
17+
this.actionMap[parseInt(substrings[0], 10)] = substrings[1]
18+
})
19+
}
20+
21+
protected onButtonPressed(keyIndex: number): void {
22+
const actionId = this.actionMap[keyIndex]
23+
if (actionId === undefined) return super.onButtonPressed(keyIndex)
24+
25+
this.prompterView.executeAction(`Shuttle button ${keyIndex} press`, actionId, ShuttleButtonTriggerMode.PRESSED)
26+
}
27+
28+
protected onButtonReleased(keyIndex: number): void {
29+
const actionId = this.actionMap[keyIndex]
30+
if (actionId === undefined) return super.onButtonReleased(keyIndex)
31+
32+
this.prompterView.executeAction(
33+
`Shuttle button ${keyIndex} release`,
34+
actionId,
35+
ShuttleButtonTriggerMode.RELEASED
36+
)
37+
}
38+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { ControllerAbstract } from './lib.js'
55
import { JoyConController } from './joycon-device.js'
66
import { KeyboardController } from './keyboard-device.js'
77
import { ShuttleKeyboardController } from './shuttle-keyboard-device.js'
8-
import { ShuttleWebHidController } from './shuttle-webhid-device.js'
98
import { XboxController } from './xbox-controller-device.js'
9+
import { CustomizableShuttleWebHidController } from './customizable-shuttle-webhid-device.js'
1010

1111
export class PrompterControlManager {
1212
private _view: PrompterViewContent
@@ -38,7 +38,7 @@ export class PrompterControlManager {
3838
this._controllers.push(new JoyConController(this._view))
3939
}
4040
if (this._view.configOptions.mode.includes(PrompterConfigMode.SHUTTLEWEBHID)) {
41-
this._controllers.push(new ShuttleWebHidController(this._view))
41+
this._controllers.push(new CustomizableShuttleWebHidController(this._view))
4242
}
4343
if (this._view.configOptions.mode.includes(PrompterConfigMode.XBOX)) {
4444
this._controllers.push(new XboxController(this._view))

packages/webui/src/client/ui/Prompter/controller/shuttle-webhid-device.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { logger } from '../../../lib/logging.js'
88
* This class handles control of the prompter using Contour Shuttle / Multimedia Controller line of devices
99
*/
1010
export class ShuttleWebHidController extends ControllerAbstract {
11-
private prompterView: PrompterViewContent
11+
protected prompterView: PrompterViewContent
1212

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

@@ -87,6 +87,7 @@ export class ShuttleWebHidController extends ControllerAbstract {
8787
logger.debug(`Button ${keyIndex} down`)
8888
})
8989
shuttle.on('up', (keyIndex: number) => {
90+
this.onButtonReleased(keyIndex)
9091
logger.debug(`Button ${keyIndex} up`)
9192
})
9293
shuttle.on('jog', (delta, value) => {
@@ -142,6 +143,10 @@ export class ShuttleWebHidController extends ControllerAbstract {
142143
}
143144
}
144145

146+
protected onButtonReleased(_keyIndex: number): void {
147+
// no-op
148+
}
149+
145150
protected onJog(delta: number): void {
146151
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
147152

0 commit comments

Comments
 (0)