Skip to content

Commit 3072c22

Browse files
committed
Posibility to talk to multiple targets at once (needs talktome v0.4.0).
1 parent 20e6f0b commit 3072c22

File tree

4 files changed

+178
-51
lines changed

4 files changed

+178
-51
lines changed

companion/HELP.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ The `Audio` preset:
4747
- keeps the muted target state visible through the red mute feedback
4848
- for `conference` and `user` targets, button press/release also sends talk
4949
- for `feed` targets, the preset is audio-only
50+
- holding multiple PTT presets at the same time addresses all of their targets in parallel
5051

5152
PTT target presets show target online/offline state, active talk state, mute state and "addressed now".
5253

@@ -77,4 +78,4 @@ Available feedbacks include:
7778

7879
Per user:
7980

80-
- reply source
81+
- reply source

src/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export function initActions(self: TalkToMeCompanionInstance, deps: ActionDeps):
110110
],
111111
callback: async (event: CompanionActionEvent) => {
112112
try {
113-
await self.executeTalkCommand(event.options)
113+
await self.executeTalkCommand(event.options, event)
114114
} catch (error: unknown) {
115115
handleCommandFailure(self, event, error, 'Talk command failed', InstanceStatus, asString)
116116
}

src/main.ts

Lines changed: 172 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import https from 'node:https'
22
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
33
import { io, Socket } from 'socket.io-client'
44
import { InstanceBase, InstanceStatus, Regex, combineRgb, runEntrypoint } from '@companion-module/base'
5+
import type { CompanionActionEvent } from '@companion-module/base'
56
import { getConfigFields } from './config.js'
67
import { initActions as defineActions } from './actions.js'
78
import { initFeedbacks as defineFeedbacks } from './feedbacks.js'
@@ -116,6 +117,7 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
116117
socket: Socket | null
117118
pollTimer: NodeJS.Timeout | null
118119
offlineFlashTimer: NodeJS.Timeout | null
120+
uiRefreshTimer: NodeJS.Timeout | null
119121
reauthPromise: Promise<void> | null
120122
users: Map<number, UserState>
121123
conferences: Map<number, { id: number; name: string }>
@@ -131,6 +133,9 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
131133
scopeUserId: number | null
132134
scopeUserName: string
133135
lastCommand: LastCommandState
136+
pendingVariableRefresh: boolean
137+
pendingDefinitionRefresh: boolean
138+
pendingFeedbackChecks: Set<string>
134139

135140
constructor(internal: unknown) {
136141
super(internal)
@@ -140,6 +145,7 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
140145
this.socket = null
141146
this.pollTimer = null
142147
this.offlineFlashTimer = null
148+
this.uiRefreshTimer = null
143149
this.reauthPromise = null
144150

145151
this.users = new Map<number, UserState>()
@@ -166,6 +172,9 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
166172
targetId: '',
167173
at: 0,
168174
}
175+
this.pendingVariableRefresh = false
176+
this.pendingDefinitionRefresh = false
177+
this.pendingFeedbackChecks = new Set()
169178
}
170179

171180
async init(config: ModuleConfig, _isFirstInit: boolean, secrets: ModuleSecrets): Promise<void> {
@@ -442,6 +451,14 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
442451
this.offlineFlashTimer = null
443452
}
444453

454+
if (this.uiRefreshTimer) {
455+
clearTimeout(this.uiRefreshTimer)
456+
this.uiRefreshTimer = null
457+
}
458+
this.pendingVariableRefresh = false
459+
this.pendingDefinitionRefresh = false
460+
this.pendingFeedbackChecks.clear()
461+
445462
if (this.socket) {
446463
this.socket.removeAllListeners()
447464
this.socket.disconnect()
@@ -478,6 +495,57 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
478495
}, 10000)
479496
}
480497

498+
scheduleUiRefresh(
499+
feedbackIds: string[] = [],
500+
{
501+
refreshVariables = true,
502+
refreshDefinitions = false,
503+
}: { refreshVariables?: boolean; refreshDefinitions?: boolean } = {},
504+
): void {
505+
if (refreshVariables) {
506+
this.pendingVariableRefresh = true
507+
}
508+
if (refreshDefinitions) {
509+
this.pendingDefinitionRefresh = true
510+
}
511+
for (const feedbackId of feedbackIds) {
512+
const normalized = asString(feedbackId)
513+
if (!normalized) continue
514+
this.pendingFeedbackChecks.add(normalized)
515+
}
516+
if (this.uiRefreshTimer) return
517+
518+
this.uiRefreshTimer = setTimeout(() => {
519+
this.flushScheduledUiRefresh()
520+
}, 20)
521+
}
522+
523+
flushScheduledUiRefresh(): void {
524+
if (this.uiRefreshTimer) {
525+
clearTimeout(this.uiRefreshTimer)
526+
this.uiRefreshTimer = null
527+
}
528+
529+
const shouldRefreshDefinitions = this.pendingDefinitionRefresh
530+
const shouldRefreshVariables = this.pendingVariableRefresh
531+
const feedbackIds = Array.from(this.pendingFeedbackChecks)
532+
533+
this.pendingDefinitionRefresh = false
534+
this.pendingVariableRefresh = false
535+
this.pendingFeedbackChecks.clear()
536+
537+
if (shouldRefreshDefinitions) {
538+
this.refreshChoiceCaches()
539+
this.refreshDefinitions()
540+
}
541+
if (shouldRefreshVariables) {
542+
this.updateVariableValuesFromState()
543+
}
544+
if (feedbackIds.length > 0) {
545+
this.checkFeedbacks(...feedbackIds)
546+
}
547+
}
548+
481549
async apiRequest(method: string, path: string, data?: unknown): Promise<AxiosResponse> {
482550
if (!this.http) {
483551
this.createHttpClient()
@@ -720,8 +788,7 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
720788

721789
this.socket.on('cut-camera', (payload) => {
722790
this.cutCameraUser = asString(payload?.user)
723-
this.updateVariableValuesFromState()
724-
this.checkFeedbacks('user_cut_camera')
791+
this.scheduleUiRefresh(['user_cut_camera'])
725792
})
726793

727794
this.socket.on('command-result', (payload) => {
@@ -886,18 +953,19 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
886953
}
887954

888955
if (changed) {
889-
this.refreshDefinitions()
890-
this.updateVariableValuesFromState()
891-
this.checkFeedbacks(
892-
'target_volume_bar',
893-
'target_muted',
894-
'target_online',
895-
'target_offline',
896-
'user_talking_target',
897-
'user_talking_reply',
898-
'target_addressed_now',
899-
'reply_available',
900-
'user_addressed_now',
956+
this.scheduleUiRefresh(
957+
[
958+
'target_volume_bar',
959+
'target_muted',
960+
'target_online',
961+
'target_offline',
962+
'user_talking_target',
963+
'user_talking_reply',
964+
'target_addressed_now',
965+
'reply_available',
966+
'user_addressed_now',
967+
],
968+
{ refreshDefinitions: true },
901969
)
902970
}
903971
}
@@ -927,29 +995,33 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
927995
const userId = Number(raw.userId)
928996
if (!Number.isFinite(userId)) return
929997

930-
const user = this.users.get(userId) || this.makeEmptyUserState(userId)
998+
const existingUser = this.users.get(userId) || null
999+
const user = existingUser || this.makeEmptyUserState(userId)
1000+
const previousName = existingUser?.name || ''
9311001
this.mergeUserState(user, raw)
9321002
this.users.set(userId, user)
9331003

934-
this.refreshChoiceCaches()
935-
this.refreshDefinitions()
936-
this.updateVariableValuesFromState()
937-
this.checkFeedbacks(
938-
'module_not_running',
939-
'user_online',
940-
'user_talking',
941-
'user_talking_target',
942-
'user_talking_reply',
943-
'user_locked',
944-
'target_volume_bar',
945-
'target_muted',
946-
'target_online',
947-
'target_offline',
948-
'target_addressed_now',
949-
'reply_available',
950-
'user_addressed_now',
951-
'operator_not_logged_in',
952-
'user_cut_camera',
1004+
this.scheduleUiRefresh(
1005+
[
1006+
'module_not_running',
1007+
'user_online',
1008+
'user_talking',
1009+
'user_talking_target',
1010+
'user_talking_reply',
1011+
'user_locked',
1012+
'target_volume_bar',
1013+
'target_muted',
1014+
'target_online',
1015+
'target_offline',
1016+
'target_addressed_now',
1017+
'reply_available',
1018+
'user_addressed_now',
1019+
'operator_not_logged_in',
1020+
'user_cut_camera',
1021+
],
1022+
{
1023+
refreshDefinitions: !existingUser || previousName !== user.name,
1024+
},
9531025
)
9541026
}
9551027

@@ -962,7 +1034,9 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
9621034
talkLocked: false,
9631035
socketId: '',
9641036
currentTarget: null,
1037+
currentTargets: [],
9651038
lastTarget: null,
1039+
lastTargets: [],
9661040
addressedNow: [],
9671041
replyTarget: null,
9681042
targetAudioStates: [],
@@ -978,8 +1052,14 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
9781052
target.talking = Boolean(raw.talking)
9791053
target.talkLocked = Boolean(raw.talkLocked)
9801054
target.socketId = asString(raw.socketId)
981-
target.currentTarget = this.normalizeStateTarget(raw.currentTarget)
982-
target.lastTarget = this.normalizeStateTarget(raw.lastTarget)
1055+
const currentTarget = this.normalizeStateTarget(raw.currentTarget)
1056+
const currentTargets = this.normalizeStateTargets(raw.currentTargets)
1057+
const lastTarget = this.normalizeStateTarget(raw.lastTarget)
1058+
const lastTargets = this.normalizeStateTargets(raw.lastTargets)
1059+
target.currentTargets = currentTargets.length > 0 ? currentTargets : currentTarget ? [currentTarget] : []
1060+
target.currentTarget = target.currentTargets[0] || currentTarget
1061+
target.lastTargets = lastTargets.length > 0 ? lastTargets : lastTarget ? [lastTarget] : []
1062+
target.lastTarget = target.lastTargets[0] || lastTarget
9831063
target.addressedNow = this.normalizeAddressedEntries(raw.addressedNow)
9841064
target.replyTarget = this.normalizeAddressedEntry(raw.replyTarget)
9851065
target.lastCommandId = asString(raw.lastCommandId)
@@ -1070,6 +1150,21 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
10701150
return { type: type as NormalizedTarget['type'], id }
10711151
}
10721152

1153+
normalizeStateTargets(rawTargets: unknown): NormalizedTarget[] {
1154+
if (!Array.isArray(rawTargets)) return []
1155+
const normalizedTargets: NormalizedTarget[] = []
1156+
const seen = new Set<string>()
1157+
for (const rawTarget of rawTargets) {
1158+
const target = this.normalizeStateTarget(rawTarget)
1159+
if (!target) continue
1160+
const key = `${target.type}:${target.id}`
1161+
if (seen.has(key)) continue
1162+
seen.add(key)
1163+
normalizedTargets.push(target)
1164+
}
1165+
return normalizedTargets
1166+
}
1167+
10731168
normalizeTimestamp(rawValue: unknown): number | null {
10741169
if (rawValue === null || rawValue === undefined || rawValue === '') return null
10751170
const numeric = Number(rawValue)
@@ -1132,26 +1227,42 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
11321227
})
11331228
}
11341229

1135-
isUserTalkingToExactTarget(userId: unknown, targetType: unknown, targetId: unknown): boolean {
1230+
getUserCurrentTargets(userId: unknown): NormalizedTarget[] {
11361231
const normalizedUserId = Number(userId)
1137-
if (!Number.isFinite(normalizedUserId)) return false
1232+
if (!Number.isFinite(normalizedUserId)) return []
11381233
const user = this.users.get(normalizedUserId)
1139-
if (!user?.talking) return false
1234+
if (!user?.talking) return []
11401235

1141-
const currentTarget = this.normalizeStateTarget(user.currentTarget)
1236+
const explicitTargets = this.normalizeStateTargets(user.currentTargets)
1237+
if (explicitTargets.length > 0) {
1238+
return explicitTargets
1239+
}
1240+
1241+
const singleTarget = this.normalizeStateTarget(user.currentTarget)
1242+
return singleTarget ? [singleTarget] : []
1243+
}
1244+
1245+
isUserTalkingToExactTarget(userId: unknown, targetType: unknown, targetId: unknown): boolean {
1246+
const normalizedUserId = Number(userId)
1247+
if (!Number.isFinite(normalizedUserId)) return false
11421248
const expectedTarget = this.normalizeStateTarget({ type: targetType, id: targetId })
1143-
return this.areTargetsEquivalent(currentTarget, expectedTarget)
1249+
if (!expectedTarget) return false
1250+
1251+
return this.getUserCurrentTargets(normalizedUserId).some((currentTarget) =>
1252+
this.areTargetsEquivalent(currentTarget, expectedTarget),
1253+
)
11441254
}
11451255

11461256
isUserTalkingToReply(userId: unknown): boolean {
11471257
const normalizedUserId = Number(userId)
11481258
if (!Number.isFinite(normalizedUserId)) return false
1149-
const user = this.users.get(normalizedUserId)
1150-
if (!user?.talking) return false
11511259

1152-
const currentTarget = this.normalizeStateTarget(user.currentTarget)
11531260
const replyTarget = this.resolveReplyReferenceTarget(normalizedUserId)
1154-
return this.areTargetsEquivalent(currentTarget, replyTarget)
1261+
if (!replyTarget) return false
1262+
1263+
return this.getUserCurrentTargets(normalizedUserId).some((currentTarget) =>
1264+
this.areTargetsEquivalent(currentTarget, replyTarget),
1265+
)
11551266
}
11561267

11571268
isAddressingEntryMatchingTarget(
@@ -1343,8 +1454,7 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
13431454
this.applyUserState(raw.state)
13441455
}
13451456

1346-
this.updateVariableValuesFromState()
1347-
this.checkFeedbacks(
1457+
this.scheduleUiRefresh([
13481458
'last_command_failed',
13491459
'user_talking',
13501460
'user_talking_target',
@@ -1358,7 +1468,7 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
13581468
'reply_available',
13591469
'user_addressed_now',
13601470
'operator_not_logged_in',
1361-
)
1471+
])
13621472

13631473
if (reason === 'Target offline') {
13641474
this.triggerTargetOfflineFeedbackFlash()
@@ -1404,7 +1514,16 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
14041514
return id
14051515
}
14061516

1407-
async executeTalkCommand(options: Record<string, unknown>): Promise<void> {
1517+
buildCompanionTalkInputKey(actionEvent: CompanionActionEvent | undefined, userId: number): string | null {
1518+
if (!actionEvent) return null
1519+
const instanceId = asString(this.id || this.label) || 'talktome'
1520+
const controlId = asString(actionEvent.controlId)
1521+
if (!controlId) return null
1522+
const surfaceId = asString(actionEvent.surfaceId) || 'surface'
1523+
return `companion:${instanceId}:user:${userId}:surface:${surfaceId}:control:${controlId}`
1524+
}
1525+
1526+
async executeTalkCommand(options: Record<string, unknown>, actionEvent?: CompanionActionEvent): Promise<void> {
14081527
const userId = this.resolveChoiceId(options.userId)
14091528
if (!userId) {
14101529
throw new Error('Invalid user')
@@ -1424,6 +1543,10 @@ export class TalkToMeCompanionInstance extends InstanceBase<ModuleConfig, Module
14241543
targetType,
14251544
waitMs,
14261545
}
1546+
const inputKey = this.buildCompanionTalkInputKey(actionEvent, userId)
1547+
if (inputKey) {
1548+
payload.inputKey = inputKey
1549+
}
14271550

14281551
if (targetType === 'conference') {
14291552
const conferenceId = this.resolveChoiceId(options.targetConferenceId)

0 commit comments

Comments
 (0)