Skip to content
1 change: 1 addition & 0 deletions companion/lib/Instance/ConfigFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ function translateCustomVariableField(
...translateCommonFields(field),
type: 'custom-variable',
width: width,
default: undefined, // type-check complains otherwise
}
}

Expand Down
14 changes: 14 additions & 0 deletions companion/lib/Surface/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,26 @@ export function createOrSanitizeSurfaceHandlerConfig(
let panelConfig = existingConfig?.config
if (!panelConfig) {
panelConfig = cloneDeep(PanelDefaults)
// add properties & defaults from their UI definitions (so a redundant `getDefaultConfig()` is not needed)
for (const cfield of panel.info.configFields) {
if (!(cfield.id in panelConfig) || 'default' in cfield) {
Object.assign(panelConfig, { [cfield.id]: cfield.default })
}
}
// if `panel.getDefaultConfig() is present, let it override the previous sources...
if (typeof panel.getDefaultConfig === 'function') {
Object.assign(panelConfig, panel.getDefaultConfig())
}

panelConfig.xOffset = Math.max(0, gridSize.minColumn)
panelConfig.yOffset = Math.max(0, gridSize.minRow)
} else {
// check for new properties but don't change existing fields
for (const cfield of panel.info.configFields) {
if (!(cfield.id in panelConfig)) {
Object.assign(panelConfig, { [cfield.id]: cfield.default })
}
}
}

if (panelConfig.xOffset === undefined || panelConfig.yOffset === undefined) {
Expand Down
25 changes: 25 additions & 0 deletions companion/lib/Surface/Handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ export class SurfaceHandler extends EventEmitter<SurfaceHandlerEvents> {

this.panel.on('click', this.#onDeviceClick.bind(this))
this.panel.on('rotate', this.#onDeviceRotate.bind(this))
this.panel.on('changePage', this.#onDeviceChangePage.bind(this))
this.panel.on('pincodeKey', this.#onDevicePincodeKey.bind(this))
this.panel.on('remove', this.#onDeviceRemove.bind(this))
this.panel.on('resized', this.#onDeviceResized.bind(this))
Expand Down Expand Up @@ -582,6 +583,21 @@ export class SurfaceHandler extends EventEmitter<SurfaceHandlerEvents> {
}
}

#onDeviceChangePage(forward: boolean): void {
if (this.#isSurfaceLocked || !this.panel) return

const pageNumber = this.#pageStore.getPageNumber(this.#currentPageId)
if (!pageNumber) return
const name = this.displayName

try {
this.#surfaces.devicePageSet(this.surfaceId, forward ? '+1' : '-1', true)
this.#logger.debug(`Change page ${pageNumber}: "${forward ? '+1' : '-1'}" initiated by ${name}`)
} catch (e) {
this.#logger.error(`Change page failed for ${name}: ${e}`)
}
}

#onDeviceRotate(x: number, y: number, rightward: boolean, pageOffset?: number): void {
if (!this.panel) return

Expand Down Expand Up @@ -727,6 +743,15 @@ export class SurfaceHandler extends EventEmitter<SurfaceHandlerEvents> {
redraw = true
}

// if this is an import, the config file may have been missing fields:
// (note: import does not call `createOrSanitizeSurfaceHandlerConfig`, which might be a better option?)
for (const cfield of this.panel.info.configFields) {
if (!(cfield.id in newconfig)) {
Object.assign(newconfig, { [cfield.id]: cfield.default })
redraw = true
}
}

this.#surfaceConfig.config = newconfig
this.#saveConfig()

Expand Down
1 change: 1 addition & 0 deletions companion/lib/Surface/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export interface SurfacePanelEvents {

click: [x: number, y: number, pressed: boolean, pageOffset?: number]
rotate: [x: number, y: number, rightward: boolean, pageOffset?: number]
changePage: [forward: boolean]
pincodeKey: [key: number]

setCustomVariable: [variableId: string, value: CompanionVariableValue]
Expand Down
52 changes: 46 additions & 6 deletions companion/lib/Surface/USB/ElgatoStreamDeck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,26 @@ export const StreamDeckJpegOptions: JPEGEncodeOptions = {
function getConfigFields(streamDeck: StreamDeck): CompanionSurfaceConfigField[] {
const fields: CompanionSurfaceConfigField[] = [...OffsetConfigFields]

fields.push(LegacyRotationConfigField)

// Hide brightness for the pedal
const hasBrightness = !!streamDeck.CONTROLS.find(
(c) => c.type === 'lcd-segment' || (c.type === 'button' && c.feedbackType !== 'none')
)
if (hasBrightness) fields.push(BrightnessConfigField)

fields.push(LegacyRotationConfigField, ...LockConfigFields)
if (streamDeck.MODEL === DeviceModelId.PLUS) {
// place it above offset, etc.
fields.push({
id: 'swipe_can_change_page',
label: 'Horizontal Swipe Changes Page',
type: 'checkbox',
default: true,
tooltip: 'Swiping horizontally on the Stream Deck+ LCD-strip will change pages, if enabled.',
} as CompanionSurfaceConfigField)
}

fields.push(...LockConfigFields)

if (streamDeck.HAS_NFC_READER)
fields.push({
Expand Down Expand Up @@ -98,6 +111,10 @@ export class SurfaceUSBElgatoStreamDeck extends EventEmitter<SurfacePanelEvents>
readonly info: SurfacePanelInfo
readonly gridSize: GridSize

readonly sdPlusLcdButtonOffset = 25
readonly sdPlusLcdButtonSpacing = 216.666
// readonly sdPlusLcdButtonWidth = 100 // not currently used, but could, if we wanted to be more precise about button locations

constructor(devicePath: string, streamDeck: StreamDeck | StreamDeckTcp) {
super()

Expand Down Expand Up @@ -203,7 +220,7 @@ export class SurfaceUSBElgatoStreamDeck extends EventEmitter<SurfacePanelEvents>
let drawX = drawColumn * columnWidth
if (this.#streamDeck.MODEL === DeviceModelId.PLUS) {
// Position aligned with the buttons/encoders
drawX = drawColumn * 216.666 + 25
drawX = drawColumn * this.sdPlusLcdButtonSpacing + this.sdPlusLcdButtonOffset
}

const targetSize = control.pixelSize.height
Expand Down Expand Up @@ -278,17 +295,40 @@ export class SurfaceUSBElgatoStreamDeck extends EventEmitter<SurfacePanelEvents>
this.emit('setCustomVariable', variableId, tag)
})

const getLCDButton = (control: StreamDeckLcdSegmentControlDefinition, x: number) => {
// Button assignment is very permissive, but maybe more compatible with the graphics overhaul?
// note: this doesn't take into account the SD Plus button offset, but that gives a little margin to the left, so maybe OK.
// TODO: reexamine when double-width buttons are implemented?
// if using the margin, add Math.max(0, Math.min(control.columnSpan-1, ... ))
const columnOffset = Math.floor((x / control.pixelSize.width) * control.columnSpan)
return control.column + columnOffset
}
const lcdPress = (control: StreamDeckLcdSegmentControlDefinition, position: LcdPosition) => {
const columnOffset = Math.floor((position.x / control.pixelSize.width) * control.columnSpan)

this.emit('click', control.column + columnOffset, control.row, true)
const buttonCol = getLCDButton(control, position.x)
this.emit('click', buttonCol, control.row, true)

setTimeout(() => {
this.emit('click', control.column + columnOffset, control.row, false)
this.emit('click', buttonCol, control.row, false)
}, 20)
}
this.#streamDeck.on('lcdShortPress', lcdPress)
this.#streamDeck.on('lcdLongPress', lcdPress)
this.#streamDeck.on('lcdSwipe', (control, from, to) => {
const angle = Math.atan(Math.abs((from.y - to.y) / (from.x - to.x))) * (180 / Math.PI)
const fromButton = getLCDButton(control, from.x)
const toButton = getLCDButton(control, to.x)
this.#logger.debug(
`LCD #${control.id} swipe: (${from.x}, ${from.y}; button:${fromButton})->(${to.x}, ${to.y}; button: ${toButton}): Angle: ${angle.toFixed(1)}`
)
// avoid ambiguous swipes, so vertical has to be "clearly vertical", so make it a bit more than 45
if (angle >= 50 && toButton === fromButton) {
//vertical swipe. note that y=0 is the top of the screen so for swipe up `from.y` is the higher
this.emit('rotate', fromButton, control.row, from.y > to.y)
} else if (angle <= 22.5 && this.config.swipe_can_change_page) {
// horizontal swipe, change pages: (note that the angle of the SD+ screen diagonal is 7 degrees, i.e. atan 1/8)
this.emit('changePage', from.x > to.x) //swipe left moves to next page, as if your finger is moving a piece of paper under the screen
}
})
}

async #init() {
Expand Down
9 changes: 8 additions & 1 deletion docs/7_surfaces/elgato_streamdeck.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ We recommend connecting Stream Decks without the Elgato software. If you use the

The Stream Deck + has rotary encoders and a touch strip.

The touch strip only provides press events to Companion; pressing it generates both down and up actions with a short delay.
The touch strip is mapped to four buttons. These buttons provide _press_ events to Companion without a separate _release_ event; "pressing" (tapping) the strip generates both down and up actions with a short delay. Consequently, the touch strip cannot respond to long-press actions.

Starting with Companion v4.2, the touch strip also supports two types of swipe responses:

- swiping horizontally will change the page: swipe left to get the next page; right to get the previous page (as if you're dragging a piece of paper that is just under the surface). To enable or disable page-turning, [change the settings in the Surfaces tab](#3_config/surfaces.md).
- swiping vertically: up emits _rotate-right_; down emits _rotate-left_ (enable the `Enable Rotary Actions` checkbox for the button to access these events).

Note that the LCD Strip may occasionally confuse a vertical swipe with a press, so best practice may be to chose either _rotation_ actions or _press_ actions for the LCD buttons but not both at once. Or else to avoid _press actions_ that change something critical, if using both.

To use the rotary encoders for a control, enable the `Enable Rotary Actions` checkbox for that control. This adds additional action groups used when rotating the encoder.

Expand Down
1 change: 1 addition & 0 deletions shared-lib/lib/Model/Options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export interface CompanionInputFieldCheckboxExtended extends CompanionInputField
}
export interface CompanionInputFieldCustomVariableExtended extends CompanionInputFieldBaseExtended {
type: 'custom-variable'
default?: never // needed to avoid TypeScript errors (all other input fields have default props)
}

export type ExtendedInputField =
Expand Down