@mentra/acs-meeting 3.2.1-dev.311 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -237,6 +237,7 @@ Audio and video control:
237
237
  | Function | Notes |
238
238
  |---|---|
239
239
  | `setMuted(muted)` | Gates the uplink chain synchronously, then applies ACS policy |
240
+ | `setVideoEnabled(enabled)` | ACS `stopVideo`/`startVideo` on the virtual outgoing stream. Glasses source and preview tap keep running; `videoEnabled` resets to true per join |
240
241
  | `setAudioSource(source)` | **No-op.** The source is locked for the call at join |
241
242
  | `pushOutgoingPcm(base64, rate, ch)` | Synchronous `Function`, ~100 calls/s. Returns whether it entered the uplink |
242
243
  | `updateVideoSource(whepUrl)` | WHEP only |
@@ -86,6 +86,7 @@ class AcsMeetingModule : Module() {
86
86
 
87
87
  override fun definition() = ModuleDefinition {
88
88
  Name("MentraAcsMeeting")
89
+ Function("supportsTeamsIdentity") { true }
89
90
  // `onScopedNetworkLost` fires only for a hotspot that went away while we still wanted it: the
90
91
  // scoped state machine drops the framework's `onLost` for a network we released ourselves, so a
91
92
  // normal Leave or End cannot manufacture a mid-call network error.
@@ -268,7 +269,7 @@ class AcsMeetingModule : Module() {
268
269
  // follows, because ACS re-binds its signalling when the network moves and keeps whatever
269
270
  // route it got. Teardown's `leaveAndAwait` is what drops it, and only once a validated
270
271
  // default internet exists — not `leaveScopedNetwork`, which used to unpin onto SoftAP.
271
- meeting.prepareAgent(token, displayName)
272
+ meeting.prepareAgent(token, displayName, options["identityMode"] as? String ?: "guest")
272
273
  meeting.snapshot()
273
274
  }
274
275
  }
@@ -326,6 +327,7 @@ class AcsMeetingModule : Module() {
326
327
  video,
327
328
  audioDelayMs,
328
329
  origin,
330
+ identityMode = options["identityMode"] as? String ?: "guest",
329
331
  bindIngestUnpinned = { bind -> withIngestUnpinned(bind) },
330
332
  )
331
333
  // Prefer the join snapshot: getState() can race a leave from a respawned miniapp
@@ -427,6 +429,18 @@ class AcsMeetingModule : Module() {
427
429
  session?.setMuted(muted) ?: mapOf("state" to "idle", "muted" to muted)
428
430
  }
429
431
 
432
+ AsyncFunction("setVideoEnabled") { enabled: Boolean, promise: Promise ->
433
+ val meeting = session
434
+ if (meeting == null) {
435
+ promise.reject("VIDEO_TOGGLE_FAILED", "No active meeting", null)
436
+ } else {
437
+ meeting.setVideoEnabled(enabled) { state, error ->
438
+ if (error != null) promise.reject("VIDEO_TOGGLE_FAILED", error.message, error)
439
+ else promise.resolve(state)
440
+ }
441
+ }
442
+ }
443
+
430
444
  AsyncFunction("setAudioSource") { source: String ->
431
445
  session?.setAudioSource(source) ?: mapOf("state" to "idle", "muted" to false, "audioSource" to source)
432
446
  }
@@ -12,7 +12,11 @@ import com.azure.android.communication.calling.AudioStreamState
12
12
  import com.azure.android.communication.calling.AudioStreamType
13
13
  import com.azure.android.communication.calling.CapabilitiesCallFeature
14
14
  import com.azure.android.communication.calling.CapabilitiesChangedListener
15
- import com.azure.android.communication.calling.Call
15
+ import com.azure.android.communication.calling.CommonCall
16
+ import com.azure.android.communication.calling.CommonCallAgent
17
+ import com.azure.android.communication.calling.TeamsCallAgent
18
+ import com.azure.android.communication.calling.TeamsCallAgentOptions
19
+ import com.azure.android.communication.calling.JoinTeamsCallOptions
16
20
  import com.azure.android.communication.calling.CallAgent
17
21
  import com.azure.android.communication.calling.CallAgentOptions
18
22
  import com.azure.android.communication.calling.CallClient
@@ -70,6 +74,7 @@ import com.mentra.glassesmedia.source.OutgoingRateArm
70
74
  import com.mentra.glassesmedia.source.PixelFormatArm
71
75
  import com.mentra.glassesmedia.source.GlassesMediaController
72
76
  import com.mentra.glassesmedia.network.ScopedSoftApNetwork
77
+ import com.mentra.glassesmedia.source.DecodedFrameTap
73
78
  import com.mentra.glassesmedia.source.GlassesMediaSourceFactory
74
79
  import com.mentra.glassesmedia.source.LocalWhipIngestSource
75
80
  import com.mentra.acsmeeting.source.MeetingVideoSourceSpec
@@ -200,12 +205,14 @@ class AcsMeetingSession(
200
205
  private var noNetworkListener: DiagnosticFlagChangedListener? = null
201
206
  private var relaysListener: DiagnosticFlagChangedListener? = null
202
207
  private var callClient: CallClient? = null
203
- private var callAgent: CallAgent? = null
204
- private var call: Call? = null
208
+ private var callAgent: CommonCallAgent? = null
209
+ private var call: CommonCall? = null
205
210
  private var audioOut: RawOutgoingAudioStream? = null
206
211
  private var localOut: LocalOutgoingAudioStream? = null
207
212
  private var audioIn: RawIncomingAudioStream? = null
208
213
  private var videoOut: VirtualOutgoingVideoStream? = null
214
+ /** Whether ACS is sending [videoOut]. Only [setVideoEnabled] turns it off; every call starts on. */
215
+ @Volatile private var videoEnabled = true
209
216
  @Volatile private var meetingUrl: String? = null
210
217
  @Volatile private var phase = "idle"
211
218
  @Volatile private var lastError: String? = null
@@ -265,13 +272,15 @@ class AcsMeetingSession(
265
272
  * `createCallAgent` Future we stopped waiting on. ACS still finishes signing in; this is the only
266
273
  * handle that can dispose that leftover agent before the next join.
267
274
  */
268
- private var abandonedAgent: Future<CallAgent>? = null
275
+ private var abandonedAgent: Future<out CommonCallAgent>? = null
269
276
  /**
270
277
  * Agent signed in before the glasses hotspot came up. SoftAP DNS cannot resolve ACS hosts, so
271
278
  * `createCallAgent` after the scoped join stalls until the hotspot is torn down. Join reuses this
272
279
  * instead of signing in again on the broken resolver.
273
280
  */
274
281
  @Volatile private var agentPrepared = false
282
+ private var preparedToken: String? = null
283
+ private var preparedIdentityMode: String? = null
275
284
  private val controller = SessionAudioController()
276
285
  private val applier = AudioPolicyApplier(controller, scheduler) { Log.i(TAG, it) }
277
286
 
@@ -279,6 +288,7 @@ class AcsMeetingSession(
279
288
  val result = mutableMapOf<String, Any>(
280
289
  "state" to phase,
281
290
  "muted" to muted.get(),
291
+ "videoEnabled" to videoEnabled,
282
292
  "provider" to "acs-teams",
283
293
  "audioSource" to audioSource,
284
294
  "activeStream" to controller.readActive().name.lowercase(),
@@ -314,7 +324,7 @@ class AcsMeetingSession(
314
324
  * hotspot too. Here nothing is torn down while we wait, and a cold sign-in on a slow AP measured
315
325
  * 35 s on device — under the old 20 s cap that agent arrived just in time to be thrown away.
316
326
  */
317
- fun prepareAgent(token: String, displayName: String?) {
327
+ fun prepareAgent(token: String, displayName: String?, identityMode: String = "guest") {
318
328
  val done = CountDownLatch(1)
319
329
  val error = AtomicReference<Exception?>(null)
320
330
  phase = "connecting"
@@ -332,8 +342,10 @@ class AcsMeetingSession(
332
342
  callClient = CallClient()
333
343
  val agentOptions = CallAgentOptions()
334
344
  agentOptions.displayName = displayName ?: "Mentra Call"
335
- callAgent = obtainCallAgent(callClient!!, credential, agentOptions, generation, PREPARE_AGENT_WAIT_MS)
345
+ callAgent = obtainCallAgent(callClient!!, credential, agentOptions, generation, identityMode, PREPARE_AGENT_WAIT_MS)
336
346
  agentPrepared = true
347
+ preparedToken = token
348
+ preparedIdentityMode = identityMode
337
349
  Log.i(TAG, "ACS call agent prepared before SoftAP")
338
350
  SoftApTrace.stage(
339
351
  "session_prepare_agent_end",
@@ -379,6 +391,7 @@ class AcsMeetingSession(
379
391
  audioDelayMs: Int? = null,
380
392
  /** `created` (Start) or `joined` (Join); see [callOrigin]. Diagnostic only. */
381
393
  origin: String = "unknown",
394
+ identityMode: String = "guest",
382
395
  /**
383
396
  * Runs the WHIP listener bind, and exists so the caller can lift a process-wide network pin
384
397
  * across exactly that call. Takes the block rather than being a pair of before/after hooks so
@@ -426,7 +439,7 @@ class AcsMeetingSession(
426
439
  // the host and miniapp flash out of "joining" on every join.
427
440
  // A SoftAP join that already signed in on cellular must keep that agent:
428
441
  // recreating it on the glasses hotspot is the ACS_AGENT_TIMEOUT we just hit.
429
- val reuseAgent = agentPrepared && callAgent != null
442
+ val reuseAgent = agentPrepared && callAgent != null && preparedToken == token && preparedIdentityMode == identityMode
430
443
  leaveLocked(emitIdle = false, keepAgent = reuseAgent)
431
444
  // After the teardown, because that teardown bumps the generation itself. Everything that
432
445
  // moves it runs on this executor, so the value is stable for the rest of this join.
@@ -483,7 +496,7 @@ class AcsMeetingSession(
483
496
  callClient = CallClient()
484
497
  val agentOptions = CallAgentOptions()
485
498
  agentOptions.displayName = displayName ?: "Mentra Call"
486
- callAgent = obtainCallAgent(callClient!!, credential, agentOptions, generation)
499
+ callAgent = obtainCallAgent(callClient!!, credential, agentOptions, generation, identityMode)
487
500
  }
488
501
 
489
502
  val videoOptions = RawOutgoingVideoStreamOptions()
@@ -632,7 +645,10 @@ class AcsMeetingSession(
632
645
  bindIngestUnpinned {
633
646
  media.attach(
634
647
  video = { planes ->
635
- frameSender.sendPlanes(planes)
648
+ // Preview first, deliberately: the tap must see frames ACS's pacing and
649
+ // readiness gates would otherwise hide, and it cannot delay or break this call.
650
+ DecodedFrameTap.offer(planes)
651
+ if (frameSender.sendPlanes(planes)) DecodedFrameTap.recordAcsSend()
636
652
  },
637
653
  pcm = { pcm, rate, channels -> feedOutgoingPcm(pcm, rate, channels) },
638
654
  config = videoSource.toConfig(),
@@ -641,7 +657,12 @@ class AcsMeetingSession(
641
657
  }
642
658
 
643
659
  val locator = TeamsMeetingLinkLocator(teamsUrl)
644
- val joined = callAgent!!.join(context, locator, joinOptions)
660
+ val joined = when (val agent = callAgent!!) {
661
+ is TeamsCallAgent -> agent.join(context, locator, JoinTeamsCallOptions()
662
+ .setOutgoingVideoOptions(ov).setOutgoingAudioOptions(oa).setIncomingAudioOptions(ia))
663
+ is CallAgent -> agent.join(context, locator, joinOptions)
664
+ else -> throw IllegalStateException("Unsupported meeting agent")
665
+ }
645
666
  call = joined
646
667
  roster.attach(joined)
647
668
  joined.addOnStateChangedListener { pushCallState(joined.state) }
@@ -654,7 +675,8 @@ class AcsMeetingSession(
654
675
  if (videoSource !is MeetingVideoSourceSpec.SoftAp) {
655
676
  media.attach(
656
677
  video = { planes ->
657
- frameSender.sendPlanes(planes)
678
+ DecodedFrameTap.offer(planes)
679
+ if (frameSender.sendPlanes(planes)) DecodedFrameTap.recordAcsSend()
658
680
  },
659
681
  pcm = { pcm, rate, channels -> feedOutgoingPcm(pcm, rate, channels) },
660
682
  config = if (synthetic) SourceConfig("", SourceKind.DIRECT) else videoSource.toConfig(),
@@ -886,6 +908,45 @@ class AcsMeetingSession(
886
908
  return snap
887
909
  }
888
910
 
911
+ /**
912
+ * Stop or resume the camera Teams receives without leaving the call. The glasses source, the
913
+ * preview tap and the WHEP/WHIP transport keep running; [frameSender] drops frames while ACS
914
+ * reports the stream stopped. [videoEnabled] only moves once ACS accepts the change.
915
+ */
916
+ fun setVideoEnabled(next: Boolean, complete: (Map<String, Any>?, Throwable?) -> Unit) {
917
+ val generation = joinGeneration.get()
918
+ executor.execute {
919
+ val active = call
920
+ val stream = videoOut
921
+ if (active == null || stream == null || joinGeneration.get() != generation) {
922
+ complete(null, IllegalStateException("No active meeting"))
923
+ return@execute
924
+ }
925
+ if (videoEnabled == next) {
926
+ complete(snapshot(), null)
927
+ return@execute
928
+ }
929
+ val result = runCatching {
930
+ val change = if (next) active.startVideo(context, stream) else active.stopVideo(context, stream)
931
+ change.get(VIDEO_TOGGLE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
932
+ }
933
+ if (call !== active || joinGeneration.get() != generation) {
934
+ complete(null, IllegalStateException("The meeting ended before the camera changed"))
935
+ return@execute
936
+ }
937
+ result.exceptionOrNull()?.let { error ->
938
+ Log.e(TAG, "setVideoEnabled=$next failed", error)
939
+ complete(null, error)
940
+ return@execute
941
+ }
942
+ Log.i(TAG, "setVideoEnabled=$next")
943
+ videoEnabled = next
944
+ val snap = snapshot()
945
+ onState(snap)
946
+ complete(snap, null)
947
+ }
948
+ }
949
+
889
950
  fun setAudioSource(source: String): Map<String, Any> {
890
951
  val parsed = AcsAudioPolicy.parseSource(source)
891
952
  if (parsed == null) {
@@ -1224,7 +1285,7 @@ class AcsMeetingSession(
1224
1285
  ""
1225
1286
  }
1226
1287
 
1227
- private fun attachMediaStats(joined: Call) {
1288
+ private fun attachMediaStats(joined: CommonCall) {
1228
1289
  detachMediaStats()
1229
1290
  mediaStatsReports.set(0)
1230
1291
  lastWireSizeKey = null
@@ -1443,7 +1504,7 @@ class AcsMeetingSession(
1443
1504
  * "sender's video is frozen" guidance points at these diagnostics instead:
1444
1505
  * they are the only send-side network signal the SDK exposes.
1445
1506
  */
1446
- private fun attachDiagnostics(joined: Call) {
1507
+ private fun attachDiagnostics(joined: CommonCall) {
1447
1508
  detachDiagnostics()
1448
1509
  try {
1449
1510
  val feature = joined.feature(Features.LOCAL_USER_DIAGNOSTICS) as LocalUserDiagnosticsCallFeature
@@ -1638,7 +1699,7 @@ class AcsMeetingSession(
1638
1699
  * The capability that matters is `HANG_UP_FOR_EVERYONE`. It can flip mid-call — a presenter role
1639
1700
  * granted or removed — so the listener stays attached rather than reading once at connect.
1640
1701
  */
1641
- private fun attachCapabilities(joined: Call) {
1702
+ private fun attachCapabilities(joined: CommonCall) {
1642
1703
  detachCapabilities()
1643
1704
  try {
1644
1705
  val feature = joined.feature(Features.CAPABILITIES)
@@ -1750,16 +1811,17 @@ class AcsMeetingSession(
1750
1811
  credential: CommunicationTokenCredential,
1751
1812
  options: CallAgentOptions,
1752
1813
  generation: Int,
1814
+ identityMode: String,
1753
1815
  waitMs: Long = CALL_AGENT_WAIT_MS,
1754
- ): CallAgent {
1816
+ ): CommonCallAgent {
1755
1817
  disposeAbandonedAgent(waitMs = ABANDONED_AGENT_REJOIN_WAIT_MS)
1756
1818
  return try {
1757
- awaitCallAgent(client, credential, options, generation, waitMs)
1819
+ awaitCallAgent(client, credential, options, generation, identityMode, waitMs)
1758
1820
  } catch (error: Exception) {
1759
1821
  if (!AbandonedCallAgent.isExistingAgentError(error)) throw error
1760
1822
  Log.w(TAG, "createCallAgent hit leftover identity; disposing abandoned agent and retrying")
1761
1823
  disposeAbandonedAgent(waitMs = ABANDONED_AGENT_REJOIN_WAIT_MS)
1762
- awaitCallAgent(client, credential, options, generation, waitMs)
1824
+ awaitCallAgent(client, credential, options, generation, identityMode, waitMs)
1763
1825
  }
1764
1826
  }
1765
1827
 
@@ -1768,11 +1830,16 @@ class AcsMeetingSession(
1768
1830
  credential: CommunicationTokenCredential,
1769
1831
  options: CallAgentOptions,
1770
1832
  generation: Int,
1833
+ identityMode: String,
1771
1834
  waitMs: Long,
1772
- ): CallAgent {
1835
+ ): CommonCallAgent {
1773
1836
  val startedAt = SystemClock.elapsedRealtime()
1774
1837
  SoftApTrace.stage("session_call_agent_wait", "waitMs" to waitMs, "generation" to generation)
1775
- val pending = client.createCallAgent(context, credential, options)
1838
+ val pending: Future<out CommonCallAgent> = when (identityMode) {
1839
+ "guest" -> client.createCallAgent(context, credential, options)
1840
+ "teams-user" -> client.createTeamsCallAgent(context, credential, TeamsCallAgentOptions())
1841
+ else -> throw IllegalArgumentException("Unsupported meeting identity mode")
1842
+ }
1776
1843
  val agent = try {
1777
1844
  pending.get(waitMs, TimeUnit.MILLISECONDS)
1778
1845
  } catch (timeout: TimeoutException) {
@@ -1815,7 +1882,7 @@ class AcsMeetingSession(
1815
1882
  * Gives up after a bounded number of sweeps: by then the process has either got the agent or the
1816
1883
  * future is never completing, and an endless timer is its own leak.
1817
1884
  */
1818
- private fun sweepLateAgent(pending: Future<CallAgent>, sweep: Int) {
1885
+ private fun sweepLateAgent(pending: Future<out CommonCallAgent>, sweep: Int) {
1819
1886
  if (sweep >= LATE_AGENT_SWEEPS) {
1820
1887
  Log.w(TAG, "abandoned call agent never completed; stopping sweep")
1821
1888
  // Giving up on the sweep is giving up on disposing that agent. It is a bounded leak by
@@ -1992,6 +2059,8 @@ class AcsMeetingSession(
1992
2059
  callClient = null
1993
2060
  call = null
1994
2061
  callAgent = null
2062
+ preparedToken = null
2063
+ preparedIdentityMode = null
1995
2064
  agentPrepared = false
1996
2065
  }
1997
2066
  media.stop()
@@ -1999,6 +2068,7 @@ class AcsMeetingSession(
1999
2068
  localOut = null
2000
2069
  audioIn = null
2001
2070
  videoOut = null
2071
+ videoEnabled = true
2002
2072
  outgoingReady.set(false)
2003
2073
  muted.set(false)
2004
2074
  hangUpForEveryone = CapabilityStatus()
@@ -2075,6 +2145,7 @@ class AcsMeetingSession(
2075
2145
  private const val TAG = "ACS-SPIKE"
2076
2146
  const val GLASSES_REQUIRES_UNMUTED_TRANSPORT = true
2077
2147
  private const val ROSTER_COALESCE_MS = 150L
2148
+ private const val VIDEO_TOGGLE_TIMEOUT_MS = 10_000L
2078
2149
  private const val MEDIA_RESTART_BASE_MS = 1_000L
2079
2150
  private const val MEDIA_RESTART_MAX_MS = 10_000L
2080
2151
 
@@ -2151,7 +2222,7 @@ class AcsMeetingSession(
2151
2222
  null -> null
2152
2223
  }
2153
2224
 
2154
- private fun describeEndReason(call: Call?): Map<String, Any?> {
2225
+ private fun describeEndReason(call: CommonCall?): Map<String, Any?> {
2155
2226
  if (call == null) return mapOf("hasCall" to false)
2156
2227
  return try {
2157
2228
  val getter = call.javaClass.methods.firstOrNull {
@@ -1,12 +1,12 @@
1
1
  package com.mentra.acsmeeting
2
2
 
3
- import com.azure.android.communication.calling.Call
3
+ import com.azure.android.communication.calling.CommonCall
4
4
  import com.azure.android.communication.calling.CallState
5
5
  import com.azure.android.communication.calling.ParticipantState
6
6
 
7
7
  /** Admit one current lobby guest without blocking the session executor or changing meeting policy. */
8
8
  internal fun admitLobbyParticipant(
9
- active: Call,
9
+ active: CommonCall,
10
10
  participantId: String,
11
11
  allowed: Boolean,
12
12
  isCurrent: () -> Boolean,
@@ -1,7 +1,7 @@
1
1
  package com.mentra.acsmeeting
2
2
 
3
3
  import android.util.Log
4
- import com.azure.android.communication.calling.Call
4
+ import com.azure.android.communication.calling.CommonCall
5
5
  import com.azure.android.communication.calling.ParticipantState
6
6
  import com.azure.android.communication.calling.ParticipantsUpdatedListener
7
7
  import com.azure.android.communication.calling.PropertyChangedListener
@@ -21,11 +21,11 @@ class RemoteRoster(
21
21
  val listener: PropertyChangedListener,
22
22
  )
23
23
 
24
- private var call: Call? = null
24
+ private var call: CommonCall? = null
25
25
  private var updatedListener: ParticipantsUpdatedListener? = null
26
26
  private val tracked = ConcurrentHashMap<String, Tracked>()
27
27
 
28
- fun attach(joined: Call) {
28
+ fun attach(joined: CommonCall) {
29
29
  detach()
30
30
  call = joined
31
31
  val listener = ParticipantsUpdatedListener { event ->
@@ -103,20 +103,21 @@ class AcsFrameSender(
103
103
 
104
104
  fun isReady(): Boolean = stream.get() != null && running.get() && format.get() != null
105
105
 
106
- fun sendPlanes(planes: I420Planes) {
106
+ /** True when the frame was submitted to ACS; false when a readiness, size, pacing or busy gate dropped it. */
107
+ fun sendPlanes(planes: I420Planes): Boolean {
107
108
  val out = stream.get()
108
109
  if (out == null || !running.get()) {
109
110
  stats.onDropNotStarted()
110
- return
111
+ return false
111
112
  }
112
113
  val negotiated = format.get()
113
114
  if (negotiated == null) {
114
115
  stats.onDropNotStarted()
115
- return
116
+ return false
116
117
  }
117
118
  if (negotiated.width != planes.width || negotiated.height != planes.height) {
118
119
  stats.onDropSize()
119
- return
120
+ return false
120
121
  }
121
122
  if (!planes.planesReadable()) {
122
123
  stats.onDropMalformed()
@@ -125,11 +126,11 @@ class AcsFrameSender(
125
126
  "P5 send plane too small ${planes.width}x${planes.height} " +
126
127
  "stride=${planes.strideY}/${planes.strideU}/${planes.strideV}",
127
128
  )
128
- return
129
+ return false
129
130
  }
130
131
  if (!pacer.tryAdmit(FramePacer.intervalNs(negotiated.framesPerSecond))) {
131
132
  stats.onDropPaced()
132
- return
133
+ return false
133
134
  }
134
135
 
135
136
  val prepared = prepareSend(planes, negotiated)
@@ -139,7 +140,7 @@ class AcsFrameSender(
139
140
  if (!gate.tryAcquire()) {
140
141
  stats.onDropBusy()
141
142
  releaseSendBuffers(prepared, planes)
142
- return
143
+ return false
143
144
  }
144
145
 
145
146
  val seq = sendSeq.incrementAndGet()
@@ -230,6 +231,7 @@ class AcsFrameSender(
230
231
  gate.release()
231
232
  releaseSendBuffers(prepared, planes)
232
233
  }
234
+ return submitted
233
235
  }
234
236
 
235
237
  private data class PreparedSend(
@@ -19,6 +19,8 @@ export type AcsMeetingParticipant = {
19
19
  export type AcsMeetingState = {
20
20
  state: MeetingPhase;
21
21
  muted: boolean;
22
+ /** Whether ACS is sending the glasses camera. Resets to true on every join. */
23
+ videoEnabled?: boolean;
22
24
  error?: string;
23
25
  meetingUrl?: string;
24
26
  provider?: "acs-teams";
@@ -45,6 +47,7 @@ export type AcsOutgoingVideo = {
45
47
  export type AcsMeetingJoinOptions = {
46
48
  meetingUrl: string;
47
49
  token: string;
50
+ identityMode?: "guest" | "teams-user";
48
51
  whepUrl?: string;
49
52
  videoSource?: {
50
53
  type: "whep";
@@ -1 +1 @@
1
- {"version":3,"file":"AcsMeeting.types.d.ts","sourceRoot":"","sources":["../src/AcsMeeting.types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,YAAY,GAAG,OAAO,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAA;AAEnG,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,OAAO,CAAA;AAChD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAA;AAC1D,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,UAAU,GAAG,QAAQ,CAAA;AAC3D;;;;GAIG;AACH,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,YAAY,GAAG,MAAM,GAAG,QAAQ,CAAA;AAE3E,MAAM,MAAM,0BAA0B,GAAG,MAAM,GAAG,YAAY,GAAG,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,cAAc,CAAA;AAEhH,MAAM,MAAM,qBAAqB,GAAG;IAClC,EAAE,EAAE,MAAM,CAAA;IACV,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,KAAK,EAAE,0BAA0B,CAAA;IACjC,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,EAAE,OAAO,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,YAAY,CAAA;IACnB,KAAK,EAAE,OAAO,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,WAAW,CAAA;IACtB,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,YAAY,CAAC,EAAE,eAAe,CAAA;IAC9B,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,WAAW,CAAC,EAAE,mBAAmB,CAAA;IACjC,wFAAwF;IACxF,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,qFAAqF;IACrF,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mFAAmF;IACnF,YAAY,CAAC,EAAE,qBAAqB,EAAE,CAAA;CACvC,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;IACX,aAAa,EAAE,MAAM,CAAA;CACtB,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,WAAW,CAAC,EAAE;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAC,GAAG;QAAC,IAAI,EAAE,QAAQ,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAC,CAAA;IACjF,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gFAAgF;IAChF,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,2DAA2D;IAC3D,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,yDAAyD;IACzD,KAAK,CAAC,EAAE,gBAAgB,CAAA;CACzB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,sBAAsB,GAAG;IACnC,OAAO,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IACzC,aAAa,EAAE,CAAC,GAAG,EAAE,mBAAmB,KAAK,IAAI,CAAA;CAClD,CAAA"}
1
+ {"version":3,"file":"AcsMeeting.types.d.ts","sourceRoot":"","sources":["../src/AcsMeeting.types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,YAAY,GAAG,OAAO,GAAG,WAAW,GAAG,cAAc,GAAG,OAAO,CAAA;AAEnG,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,OAAO,CAAA;AAChD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAA;AAC1D,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,UAAU,GAAG,QAAQ,CAAA;AAC3D;;;;GAIG;AACH,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,YAAY,GAAG,MAAM,GAAG,QAAQ,CAAA;AAE3E,MAAM,MAAM,0BAA0B,GAAG,MAAM,GAAG,YAAY,GAAG,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,cAAc,CAAA;AAEhH,MAAM,MAAM,qBAAqB,GAAG;IAClC,EAAE,EAAE,MAAM,CAAA;IACV,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,KAAK,EAAE,0BAA0B,CAAA;IACjC,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,EAAE,OAAO,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,YAAY,CAAA;IACnB,KAAK,EAAE,OAAO,CAAA;IACd,+EAA+E;IAC/E,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,WAAW,CAAA;IACtB,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,YAAY,CAAC,EAAE,eAAe,CAAA;IAC9B,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,WAAW,CAAC,EAAE,mBAAmB,CAAA;IACjC,wFAAwF;IACxF,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,qFAAqF;IACrF,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mFAAmF;IACnF,YAAY,CAAC,EAAE,qBAAqB,EAAE,CAAA;CACvC,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;IACX,aAAa,EAAE,MAAM,CAAA;CACtB,CAAA;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;IACb,YAAY,CAAC,EAAE,OAAO,GAAG,YAAY,CAAA;IACrC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,WAAW,CAAC,EAAE;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAC,GAAG;QAAC,IAAI,EAAE,QAAQ,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAC,CAAA;IACjF,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gFAAgF;IAChF,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,2DAA2D;IAC3D,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,yDAAyD;IACzD,KAAK,CAAC,EAAE,gBAAgB,CAAA;CACzB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,sBAAsB,GAAG;IACnC,OAAO,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IACzC,aAAa,EAAE,CAAC,GAAG,EAAE,mBAAmB,KAAK,IAAI,CAAA;CAClD,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"AcsMeeting.types.js","sourceRoot":"","sources":["../src/AcsMeeting.types.ts"],"names":[],"mappings":"","sourcesContent":["export type MeetingPhase = \"idle\" | \"connecting\" | \"lobby\" | \"connected\" | \"disconnected\" | \"error\"\n\nexport type AcsAudioSource = \"glasses\" | \"phone\"\nexport type AcsActiveStream = \"none\" | \"virtual\" | \"local\"\nexport type AcsAudioSafety = \"safe\" | \"degraded\" | \"unsafe\"\n/**\n * Health of the glasses WHEP subscription feeding the call. `failed` = ICE dropped or\n * the WHEP endpoint went away while the ACS call may still be `connected`. Native\n * rebuilds it on its own with backoff; the host can force one via `restartVideoSource`.\n */\nexport type AcsMediaSourceState = \"idle\" | \"connecting\" | \"live\" | \"failed\"\n\nexport type AcsMeetingParticipantState = \"idle\" | \"connecting\" | \"connected\" | \"lobby\" | \"hold\" | \"disconnected\"\n\nexport type AcsMeetingParticipant = {\n id: string\n displayName: string | null\n state: AcsMeetingParticipantState\n isMuted: boolean\n isSpeaking: boolean\n}\n\nexport type AcsMeetingState = {\n state: MeetingPhase\n muted: boolean\n error?: string\n meetingUrl?: string\n provider?: \"acs-teams\"\n audioSource?: AcsAudioSource\n activeStream?: AcsActiveStream\n audioSafety?: AcsAudioSafety\n mediaSource?: AcsMediaSourceState\n /** Native receiver diagnostic, including the reason a local WHIP offer was rejected. */\n mediaSourceReason?: string\n /** ACS disconnect diagnostics, emitted when the final disconnected state arrives. */\n endReason_code?: number\n endReason_subcode?: number\n /** Local WHIP endpoint, available after a SoftAP join or `rebindSoftApIngest`. */\n ingestUrl?: string\n /** Remote roster, including participant state, mute state and speaking updates. */\n participants?: AcsMeetingParticipant[]\n}\n\nexport type AcsOutgoingVideo = {\n width: number\n height: number\n fps: number\n maxBitrateBps: number\n}\n\nexport type AcsMeetingJoinOptions = {\n meetingUrl: string\n token: string\n whepUrl?: string\n videoSource?: {type: \"whep\"; url: string} | {type: \"softap\"; bindAddress: string}\n displayName?: string\n /** \"glasses\" sends WHEP PCM. \"phone\" uses the ACS local mic (handset or BT). */\n audioSource?: AcsAudioSource\n /**\n * Hold outgoing PCM this many ms before ACS. SoftAP+LC3 sets this in the host\n * because BLE audio reaches the phone before SoftAP video.\n */\n audioDelayMs?: number\n /** Dump WHEP PCM to a WAV in cache for P4 verification. */\n dumpPcmWav?: boolean\n /** When omitted, native keeps 1280×720@15 / 2.5 Mbps. */\n video?: AcsOutgoingVideo\n}\n\nexport type AcsIncomingPcmEvent = {\n base64: string\n sampleRate: number\n channels: number\n}\n\nexport type AcsMeetingModuleEvents = {\n onState: (state: AcsMeetingState) => void\n onIncomingPcm: (pcm: AcsIncomingPcmEvent) => void\n}\n"]}
1
+ {"version":3,"file":"AcsMeeting.types.js","sourceRoot":"","sources":["../src/AcsMeeting.types.ts"],"names":[],"mappings":"","sourcesContent":["export type MeetingPhase = \"idle\" | \"connecting\" | \"lobby\" | \"connected\" | \"disconnected\" | \"error\"\n\nexport type AcsAudioSource = \"glasses\" | \"phone\"\nexport type AcsActiveStream = \"none\" | \"virtual\" | \"local\"\nexport type AcsAudioSafety = \"safe\" | \"degraded\" | \"unsafe\"\n/**\n * Health of the glasses WHEP subscription feeding the call. `failed` = ICE dropped or\n * the WHEP endpoint went away while the ACS call may still be `connected`. Native\n * rebuilds it on its own with backoff; the host can force one via `restartVideoSource`.\n */\nexport type AcsMediaSourceState = \"idle\" | \"connecting\" | \"live\" | \"failed\"\n\nexport type AcsMeetingParticipantState = \"idle\" | \"connecting\" | \"connected\" | \"lobby\" | \"hold\" | \"disconnected\"\n\nexport type AcsMeetingParticipant = {\n id: string\n displayName: string | null\n state: AcsMeetingParticipantState\n isMuted: boolean\n isSpeaking: boolean\n}\n\nexport type AcsMeetingState = {\n state: MeetingPhase\n muted: boolean\n /** Whether ACS is sending the glasses camera. Resets to true on every join. */\n videoEnabled?: boolean\n error?: string\n meetingUrl?: string\n provider?: \"acs-teams\"\n audioSource?: AcsAudioSource\n activeStream?: AcsActiveStream\n audioSafety?: AcsAudioSafety\n mediaSource?: AcsMediaSourceState\n /** Native receiver diagnostic, including the reason a local WHIP offer was rejected. */\n mediaSourceReason?: string\n /** ACS disconnect diagnostics, emitted when the final disconnected state arrives. */\n endReason_code?: number\n endReason_subcode?: number\n /** Local WHIP endpoint, available after a SoftAP join or `rebindSoftApIngest`. */\n ingestUrl?: string\n /** Remote roster, including participant state, mute state and speaking updates. */\n participants?: AcsMeetingParticipant[]\n}\n\nexport type AcsOutgoingVideo = {\n width: number\n height: number\n fps: number\n maxBitrateBps: number\n}\n\nexport type AcsMeetingJoinOptions = {\n meetingUrl: string\n token: string\n identityMode?: \"guest\" | \"teams-user\"\n whepUrl?: string\n videoSource?: {type: \"whep\"; url: string} | {type: \"softap\"; bindAddress: string}\n displayName?: string\n /** \"glasses\" sends WHEP PCM. \"phone\" uses the ACS local mic (handset or BT). */\n audioSource?: AcsAudioSource\n /**\n * Hold outgoing PCM this many ms before ACS. SoftAP+LC3 sets this in the host\n * because BLE audio reaches the phone before SoftAP video.\n */\n audioDelayMs?: number\n /** Dump WHEP PCM to a WAV in cache for P4 verification. */\n dumpPcmWav?: boolean\n /** When omitted, native keeps 1280×720@15 / 2.5 Mbps. */\n video?: AcsOutgoingVideo\n}\n\nexport type AcsIncomingPcmEvent = {\n base64: string\n sampleRate: number\n channels: number\n}\n\nexport type AcsMeetingModuleEvents = {\n onState: (state: AcsMeetingState) => void\n onIncomingPcm: (pcm: AcsIncomingPcmEvent) => void\n}\n"]}
@@ -1,11 +1,13 @@
1
1
  import { NativeModule } from "expo";
2
2
  import type { AcsMeetingJoinOptions, AcsMeetingModuleEvents, AcsMeetingState } from "./AcsMeeting.types";
3
3
  declare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents> {
4
+ supportsTeamsIdentity(): boolean;
4
5
  join(options: AcsMeetingJoinOptions): Promise<AcsMeetingState>;
5
6
  /** Sign in to ACS before SoftAP so Teams is not resolved through glasses DNS. */
6
7
  prepareAgent(options: {
7
8
  token: string;
8
9
  displayName?: string;
10
+ identityMode?: "guest" | "teams-user";
9
11
  }): Promise<AcsMeetingState>;
10
12
  leave(): Promise<void>;
11
13
  admitParticipant?(participantId: string): Promise<void>;
@@ -19,6 +21,11 @@ declare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents
19
21
  completed: boolean;
20
22
  }>;
21
23
  setMuted(muted: boolean): Promise<AcsMeetingState>;
24
+ /**
25
+ * Stop or resume the outgoing video stream (ACS `stopVideo`/`startVideo`). The call, the glasses
26
+ * media source and the preview tap stay up; frames are dropped before ACS while stopped.
27
+ */
28
+ setVideoEnabled(enabled: boolean): Promise<AcsMeetingState>;
22
29
  setAudioSource(source: "glasses" | "phone"): Promise<AcsMeetingState>;
23
30
  updateVideoSource(whepUrl: string): Promise<void>;
24
31
  /** Force a WHEP rebuild on the current URL (phone changed networks). */
@@ -1 +1 @@
1
- {"version":3,"file":"AcsMeetingModule.d.ts","sourceRoot":"","sources":["../src/AcsMeetingModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,YAAY,EAAsB,MAAM,MAAM,CAAA;AAEtD,OAAO,KAAK,EAAC,qBAAqB,EAAE,sBAAsB,EAAE,eAAe,EAAC,MAAM,oBAAoB,CAAA;AAEtG,OAAO,OAAO,sBAAuB,SAAQ,YAAY,CAAC,sBAAsB,CAAC;IAC/E,IAAI,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC;IAC9D,iFAAiF;IACjF,YAAY,CAAC,OAAO,EAAE;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,eAAe,CAAC;IACtF,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IACtB,gBAAgB,CAAC,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IACvD;;;OAGG;IACH,aAAa,CAAC,OAAO,EAAE;QAAC,SAAS,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC;QAAC,SAAS,EAAE,OAAO,CAAA;KAAC,CAAC;IAC1E,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;IAClD,cAAc,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;IACrE,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IACjD,wEAAwE;IACxE,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;IACnC;;;;OAIG;IACH,kBAAkB,IAAI,OAAO,CAAC,MAAM,CAAC;IACrC,4EAA4E;IAC5E,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IACpE,uFAAuF;IACvF,4BAA4B,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IACjG,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAC1C,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;IACnC,uBAAuB,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC;IACzC,+BAA+B,CAAC,IAAI,OAAO,CAAC;QAAC,MAAM,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAC,CAAC;IACjG,qEAAqE;IACrE,kBAAkB,IAAI,OAAO,CAAC;QAAC,SAAS,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAC,CAAC;IACnE,QAAQ,IAAI,OAAO,CAAC,eAAe,CAAC;CACrC;;AAED,wBAA8E"}
1
+ {"version":3,"file":"AcsMeetingModule.d.ts","sourceRoot":"","sources":["../src/AcsMeetingModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,YAAY,EAAsB,MAAM,MAAM,CAAA;AAEtD,OAAO,KAAK,EAAC,qBAAqB,EAAE,sBAAsB,EAAE,eAAe,EAAC,MAAM,oBAAoB,CAAA;AAEtG,OAAO,OAAO,sBAAuB,SAAQ,YAAY,CAAC,sBAAsB,CAAC;IAC/E,qBAAqB,IAAI,OAAO;IAChC,IAAI,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC;IAC9D,iFAAiF;IACjF,YAAY,CAAC,OAAO,EAAE;QACpB,KAAK,EAAE,MAAM,CAAA;QACb,WAAW,CAAC,EAAE,MAAM,CAAA;QACpB,YAAY,CAAC,EAAE,OAAO,GAAG,YAAY,CAAA;KACtC,GAAG,OAAO,CAAC,eAAe,CAAC;IAC5B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IACtB,gBAAgB,CAAC,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IACvD;;;OAGG;IACH,aAAa,CAAC,OAAO,EAAE;QAAC,SAAS,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC;QAAC,SAAS,EAAE,OAAO,CAAA;KAAC,CAAC;IAC1E,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;IAClD;;;OAGG;IACH,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;IAC3D,cAAc,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;IACrE,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IACjD,wEAAwE;IACxE,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;IACnC;;;;OAIG;IACH,kBAAkB,IAAI,OAAO,CAAC,MAAM,CAAC;IACrC,4EAA4E;IAC5E,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IACpE,uFAAuF;IACvF,4BAA4B,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IACjG,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAC1C,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;IACnC,uBAAuB,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC;IACzC,+BAA+B,CAAC,IAAI,OAAO,CAAC;QAAC,MAAM,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAC,CAAC;IACjG,qEAAqE;IACrE,kBAAkB,IAAI,OAAO,CAAC;QAAC,SAAS,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAC,CAAC;IACnE,QAAQ,IAAI,OAAO,CAAC,eAAe,CAAC;CACrC;;AAED,wBAA8E"}
@@ -1 +1 @@
1
- {"version":3,"file":"AcsMeetingModule.js","sourceRoot":"","sources":["../src/AcsMeetingModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,mBAAmB,EAAC,MAAM,MAAM,CAAA;AAuCtD,eAAe,mBAAmB,CAAyB,kBAAkB,CAAC,CAAA","sourcesContent":["import {NativeModule, requireNativeModule} from \"expo\"\n\nimport type {AcsMeetingJoinOptions, AcsMeetingModuleEvents, AcsMeetingState} from \"./AcsMeeting.types\"\n\ndeclare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents> {\n join(options: AcsMeetingJoinOptions): Promise<AcsMeetingState>\n /** Sign in to ACS before SoftAP so Teams is not resolved through glasses DNS. */\n prepareAgent(options: {token: string; displayName?: string}): Promise<AcsMeetingState>\n leave(): Promise<void>\n admitParticipant?(participantId: string): Promise<void>\n /**\n * Leave, and resolve only once the hang-up, the agent disposal, and the network releases have\n * finished. Use this explicit barrier across platforms; Android `leave()` only queues cleanup.\n */\n leaveAndAwait(options: {timeoutMs: number}): Promise<{completed: boolean}>\n setMuted(muted: boolean): Promise<AcsMeetingState>\n setAudioSource(source: \"glasses\" | \"phone\"): Promise<AcsMeetingState>\n updateVideoSource(whepUrl: string): Promise<void>\n /** Force a WHEP rebuild on the current URL (phone changed networks). */\n restartVideoSource(): Promise<void>\n /**\n * SoftAP: destroy the current ingest listener generation and bind a new one.\n * Resolves with the new URL the glasses must publish to. Rejects rather than\n * reuse a stale listener.\n */\n rebindSoftApIngest(): Promise<string>\n /** SoftAP: join the glasses hotspot; resolves to the phone's IPv4 on it. */\n joinScopedNetwork(ssid: string, passphrase: string): Promise<string>\n /** iOS: verify DHCP against the gateway advertised by the glasses before resolving. */\n joinScopedNetworkWithGateway?(ssid: string, passphrase: string, gateway: string): Promise<string>\n beginTrace(traceId: string): Promise<void>\n leaveScopedNetwork(): Promise<void>\n cancelScopedNetworkJoin?(): Promise<void>\n awaitDefaultNetworkAfterHotspot?(): Promise<{usable: boolean; detail: string; transport: string}>\n /** SoftAP: TCP-probe the hotspot gateway over the scoped network. */\n probeScopedGateway(): Promise<{reachable: boolean; detail: string}>\n getState(): Promise<AcsMeetingState>\n}\n\nexport default requireNativeModule<AcsMeetingNativeModule>(\"MentraAcsMeeting\")\n"]}
1
+ {"version":3,"file":"AcsMeetingModule.js","sourceRoot":"","sources":["../src/AcsMeetingModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,mBAAmB,EAAC,MAAM,MAAM,CAAA;AAiDtD,eAAe,mBAAmB,CAAyB,kBAAkB,CAAC,CAAA","sourcesContent":["import {NativeModule, requireNativeModule} from \"expo\"\n\nimport type {AcsMeetingJoinOptions, AcsMeetingModuleEvents, AcsMeetingState} from \"./AcsMeeting.types\"\n\ndeclare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents> {\n supportsTeamsIdentity(): boolean\n join(options: AcsMeetingJoinOptions): Promise<AcsMeetingState>\n /** Sign in to ACS before SoftAP so Teams is not resolved through glasses DNS. */\n prepareAgent(options: {\n token: string\n displayName?: string\n identityMode?: \"guest\" | \"teams-user\"\n }): Promise<AcsMeetingState>\n leave(): Promise<void>\n admitParticipant?(participantId: string): Promise<void>\n /**\n * Leave, and resolve only once the hang-up, the agent disposal, and the network releases have\n * finished. Use this explicit barrier across platforms; Android `leave()` only queues cleanup.\n */\n leaveAndAwait(options: {timeoutMs: number}): Promise<{completed: boolean}>\n setMuted(muted: boolean): Promise<AcsMeetingState>\n /**\n * Stop or resume the outgoing video stream (ACS `stopVideo`/`startVideo`). The call, the glasses\n * media source and the preview tap stay up; frames are dropped before ACS while stopped.\n */\n setVideoEnabled(enabled: boolean): Promise<AcsMeetingState>\n setAudioSource(source: \"glasses\" | \"phone\"): Promise<AcsMeetingState>\n updateVideoSource(whepUrl: string): Promise<void>\n /** Force a WHEP rebuild on the current URL (phone changed networks). */\n restartVideoSource(): Promise<void>\n /**\n * SoftAP: destroy the current ingest listener generation and bind a new one.\n * Resolves with the new URL the glasses must publish to. Rejects rather than\n * reuse a stale listener.\n */\n rebindSoftApIngest(): Promise<string>\n /** SoftAP: join the glasses hotspot; resolves to the phone's IPv4 on it. */\n joinScopedNetwork(ssid: string, passphrase: string): Promise<string>\n /** iOS: verify DHCP against the gateway advertised by the glasses before resolving. */\n joinScopedNetworkWithGateway?(ssid: string, passphrase: string, gateway: string): Promise<string>\n beginTrace(traceId: string): Promise<void>\n leaveScopedNetwork(): Promise<void>\n cancelScopedNetworkJoin?(): Promise<void>\n awaitDefaultNetworkAfterHotspot?(): Promise<{usable: boolean; detail: string; transport: string}>\n /** SoftAP: TCP-probe the hotspot gateway over the scoped network. */\n probeScopedGateway(): Promise<{reachable: boolean; detail: string}>\n getState(): Promise<AcsMeetingState>\n}\n\nexport default requireNativeModule<AcsMeetingNativeModule>(\"MentraAcsMeeting\")\n"]}
@@ -4,6 +4,7 @@ declare const _default: {
4
4
  prepareAgent(_options: {
5
5
  token: string;
6
6
  displayName?: string;
7
+ identityMode?: "guest" | "teams-user";
7
8
  }): Promise<AcsMeetingState>;
8
9
  leave(): Promise<void>;
9
10
  leaveAndAwait(_options: {
@@ -12,6 +13,7 @@ declare const _default: {
12
13
  completed: boolean;
13
14
  }>;
14
15
  setMuted(_muted: boolean): Promise<AcsMeetingState>;
16
+ setVideoEnabled(_enabled: boolean): Promise<AcsMeetingState>;
15
17
  setAudioSource(_source: "glasses" | "phone"): Promise<AcsMeetingState>;
16
18
  updateVideoSource(_whepUrl: string): Promise<void>;
17
19
  restartVideoSource(): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"AcsMeetingModule.web.d.ts","sourceRoot":"","sources":["../src/AcsMeetingModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,qBAAqB,EAAE,eAAe,EAAC,MAAM,oBAAoB,CAAA;;mBAOvD,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC;2BAGxC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,eAAe,CAAC;aAG9E,OAAO,CAAC,IAAI,CAAC;4BAGE;QAAC,SAAS,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC;QAAC,SAAS,EAAE,OAAO,CAAA;KAAC,CAAC;qBAG1D,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;4BAG3B,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;gCAG1C,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;0BAG5B,OAAO,CAAC,IAAI,CAAC;0BAGb,OAAO,CAAC,MAAM,CAAC;6BAGZ,MAAM,eAAe,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;0BAGhD,OAAO,CAAC,IAAI,CAAC;0BACb,OAAO,CAAC;QAAC,SAAS,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAC,CAAC;gBAGvD,OAAO,CAAC,eAAe,CAAC;;;;;;AAnC5C,wBA0CC"}
1
+ {"version":3,"file":"AcsMeetingModule.web.d.ts","sourceRoot":"","sources":["../src/AcsMeetingModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,qBAAqB,EAAE,eAAe,EAAC,MAAM,oBAAoB,CAAA;;mBAOvD,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC;2BAGxC;QAC3B,KAAK,EAAE,MAAM,CAAA;QACb,WAAW,CAAC,EAAE,MAAM,CAAA;QACpB,YAAY,CAAC,EAAE,OAAO,GAAG,YAAY,CAAA;KACtC,GAAG,OAAO,CAAC,eAAe,CAAC;aAGb,OAAO,CAAC,IAAI,CAAC;4BAGE;QAAC,SAAS,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC;QAAC,SAAS,EAAE,OAAO,CAAA;KAAC,CAAC;qBAG1D,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;8BAGzB,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;4BAGpC,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC;gCAG1C,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;0BAG5B,OAAO,CAAC,IAAI,CAAC;0BAGb,OAAO,CAAC,MAAM,CAAC;6BAGZ,MAAM,eAAe,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;0BAGhD,OAAO,CAAC,IAAI,CAAC;0BACb,OAAO,CAAC;QAAC,SAAS,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAC,CAAC;gBAGvD,OAAO,CAAC,eAAe,CAAC;;;;;;AA1C5C,wBAiDC"}
@@ -17,6 +17,9 @@ export default {
17
17
  async setMuted(_muted) {
18
18
  unavailable();
19
19
  },
20
+ async setVideoEnabled(_enabled) {
21
+ unavailable();
22
+ },
20
23
  async setAudioSource(_source) {
21
24
  unavailable();
22
25
  },
@@ -1 +1 @@
1
- {"version":3,"file":"AcsMeetingModule.web.js","sourceRoot":"","sources":["../src/AcsMeetingModule.web.ts"],"names":[],"mappings":"AAEA,SAAS,WAAW;IAClB,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAA;AACxD,CAAC;AAED,eAAe;IACb,KAAK,CAAC,IAAI,CAAC,QAA+B;QACxC,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,YAAY,CAAC,QAA+C;QAChE,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,KAAK;QACT,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,aAAa,CAAC,QAA6B;QAC/C,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,QAAQ,CAAC,MAAe;QAC5B,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,cAAc,CAAC,OAA4B;QAC/C,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,iBAAiB,CAAC,QAAgB;QACtC,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,kBAAkB;QACtB,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,kBAAkB;QACtB,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,iBAAiB,CAAC,KAAa,EAAE,WAAmB;QACxD,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,kBAAkB,KAAmB,CAAC;IAC5C,KAAK,CAAC,kBAAkB;QACtB,OAAO,EAAC,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,0BAA0B,EAAC,CAAA;IAC/D,CAAC;IACD,KAAK,CAAC,QAAQ;QACZ,OAAO,EAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAC,CAAA;IACtC,CAAC;IACD,WAAW;QACT,OAAO,EAAC,MAAM,KAAI,CAAC,EAAC,CAAA;IACtB,CAAC;IACD,eAAe,KAAI,CAAC;CACrB,CAAA","sourcesContent":["import type {AcsMeetingJoinOptions, AcsMeetingState} from \"./AcsMeeting.types\"\n\nfunction unavailable(): never {\n throw new Error(\"ACS meeting is not available on web\")\n}\n\nexport default {\n async join(_options: AcsMeetingJoinOptions): Promise<AcsMeetingState> {\n unavailable()\n },\n async prepareAgent(_options: {token: string; displayName?: string}): Promise<AcsMeetingState> {\n unavailable()\n },\n async leave(): Promise<void> {\n unavailable()\n },\n async leaveAndAwait(_options: {timeoutMs: number}): Promise<{completed: boolean}> {\n unavailable()\n },\n async setMuted(_muted: boolean): Promise<AcsMeetingState> {\n unavailable()\n },\n async setAudioSource(_source: \"glasses\" | \"phone\"): Promise<AcsMeetingState> {\n unavailable()\n },\n async updateVideoSource(_whepUrl: string): Promise<void> {\n unavailable()\n },\n async restartVideoSource(): Promise<void> {\n unavailable()\n },\n async rebindSoftApIngest(): Promise<string> {\n unavailable()\n },\n async joinScopedNetwork(_ssid: string, _passphrase: string): Promise<string> {\n unavailable()\n },\n async leaveScopedNetwork(): Promise<void> {},\n async probeScopedGateway(): Promise<{reachable: boolean; detail: string}> {\n return {reachable: false, detail: \"no scoped network on web\"}\n },\n async getState(): Promise<AcsMeetingState> {\n return {state: \"idle\", muted: false}\n },\n addListener() {\n return {remove() {}}\n },\n removeListeners() {},\n}\n"]}
1
+ {"version":3,"file":"AcsMeetingModule.web.js","sourceRoot":"","sources":["../src/AcsMeetingModule.web.ts"],"names":[],"mappings":"AAEA,SAAS,WAAW;IAClB,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAA;AACxD,CAAC;AAED,eAAe;IACb,KAAK,CAAC,IAAI,CAAC,QAA+B;QACxC,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,YAAY,CAAC,QAIlB;QACC,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,KAAK;QACT,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,aAAa,CAAC,QAA6B;QAC/C,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,QAAQ,CAAC,MAAe;QAC5B,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,eAAe,CAAC,QAAiB;QACrC,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,cAAc,CAAC,OAA4B;QAC/C,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,iBAAiB,CAAC,QAAgB;QACtC,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,kBAAkB;QACtB,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,kBAAkB;QACtB,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,iBAAiB,CAAC,KAAa,EAAE,WAAmB;QACxD,WAAW,EAAE,CAAA;IACf,CAAC;IACD,KAAK,CAAC,kBAAkB,KAAmB,CAAC;IAC5C,KAAK,CAAC,kBAAkB;QACtB,OAAO,EAAC,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,0BAA0B,EAAC,CAAA;IAC/D,CAAC;IACD,KAAK,CAAC,QAAQ;QACZ,OAAO,EAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAC,CAAA;IACtC,CAAC;IACD,WAAW;QACT,OAAO,EAAC,MAAM,KAAI,CAAC,EAAC,CAAA;IACtB,CAAC;IACD,eAAe,KAAI,CAAC;CACrB,CAAA","sourcesContent":["import type {AcsMeetingJoinOptions, AcsMeetingState} from \"./AcsMeeting.types\"\n\nfunction unavailable(): never {\n throw new Error(\"ACS meeting is not available on web\")\n}\n\nexport default {\n async join(_options: AcsMeetingJoinOptions): Promise<AcsMeetingState> {\n unavailable()\n },\n async prepareAgent(_options: {\n token: string\n displayName?: string\n identityMode?: \"guest\" | \"teams-user\"\n }): Promise<AcsMeetingState> {\n unavailable()\n },\n async leave(): Promise<void> {\n unavailable()\n },\n async leaveAndAwait(_options: {timeoutMs: number}): Promise<{completed: boolean}> {\n unavailable()\n },\n async setMuted(_muted: boolean): Promise<AcsMeetingState> {\n unavailable()\n },\n async setVideoEnabled(_enabled: boolean): Promise<AcsMeetingState> {\n unavailable()\n },\n async setAudioSource(_source: \"glasses\" | \"phone\"): Promise<AcsMeetingState> {\n unavailable()\n },\n async updateVideoSource(_whepUrl: string): Promise<void> {\n unavailable()\n },\n async restartVideoSource(): Promise<void> {\n unavailable()\n },\n async rebindSoftApIngest(): Promise<string> {\n unavailable()\n },\n async joinScopedNetwork(_ssid: string, _passphrase: string): Promise<string> {\n unavailable()\n },\n async leaveScopedNetwork(): Promise<void> {},\n async probeScopedGateway(): Promise<{reachable: boolean; detail: string}> {\n return {reachable: false, detail: \"no scoped network on web\"}\n },\n async getState(): Promise<AcsMeetingState> {\n return {state: \"idle\", muted: false}\n },\n addListener() {\n return {remove() {}}\n },\n removeListeners() {},\n}\n"]}
@@ -20,21 +20,23 @@ final class AcsFrameSender: NSObject {
20
20
  stream.delegate = self
21
21
  }
22
22
 
23
- func send(_ pixelBuffer: CVPixelBuffer) {
23
+ /// True when the frame was handed to ACS; false when readiness, pacing or the send gate dropped it.
24
+ @discardableResult
25
+ func send(_ pixelBuffer: CVPixelBuffer) -> Bool {
24
26
  stateLock.lock()
25
27
  guard running, let stream else {
26
28
  stateLock.unlock()
27
- return
29
+ return false
28
30
  }
29
31
  let fps = stream.format.framesPerSecond
30
32
  let now = CFAbsoluteTimeGetCurrent()
31
33
  if lastSent > 0, now - lastSent < 1.0 / Double(max(fps, 1)) {
32
34
  stateLock.unlock()
33
- return
35
+ return false
34
36
  }
35
37
  guard let token = sendGate.acquire() else {
36
38
  stateLock.unlock()
37
- return
39
+ return false
38
40
  }
39
41
  lastSent = now
40
42
  stateLock.unlock()
@@ -48,6 +50,7 @@ final class AcsFrameSender: NSObject {
48
50
  sendGate.release(token)
49
51
  if let error { NSLog("ACS-SPIKE send frame failed: \(error)") }
50
52
  }
53
+ return true
51
54
  }
52
55
 
53
56
  func detach() {
@@ -38,11 +38,12 @@ public class AcsMeetingModule: Module {
38
38
 
39
39
  public func definition() -> ModuleDefinition {
40
40
  Name("MentraAcsMeeting")
41
+ Function("supportsTeamsIdentity") { true }
41
42
  Events("onState", "onIncomingPcm", "onScopedNetworkLost", "onScopedNetworkProgress")
42
43
 
43
44
  AsyncFunction("prepareAgent") { (options: [String: Any], promise: Promise) in
44
45
  let token = try requireString(options, "token")
45
- self.meetingSession().prepareAgent(token: token, displayName: options["displayName"] as? String) { result in
46
+ self.meetingSession().prepareAgent(token: token, displayName: options["displayName"] as? String, identityMode: options["identityMode"] as? String ?? "guest") { result in
46
47
  switch result {
47
48
  case let .success(state): promise.resolve(state)
48
49
  case let .failure(error): promise.reject(error)
@@ -59,7 +60,7 @@ public class AcsMeetingModule: Module {
59
60
  }
60
61
  let video = try parseAcsOutgoingVideo(options["video"])
61
62
  self.meetingSession().join(
62
- token: token, meetingUrl: meetingUrl, sourceConfig: source,
63
+ token: token, identityMode: options["identityMode"] as? String ?? "guest", meetingUrl: meetingUrl, sourceConfig: source,
63
64
  displayName: options["displayName"] as? String,
64
65
  dumpWav: options["dumpPcmWav"] as? Bool ?? false,
65
66
  audioSource: options["audioSource"] as? String ?? "glasses", video: video
@@ -163,6 +164,19 @@ public class AcsMeetingModule: Module {
163
164
  session.setMuted(muted) { promise.resolve($0) }
164
165
  }
165
166
 
167
+ AsyncFunction("setVideoEnabled") { (enabled: Bool, promise: Promise) in
168
+ guard let session = self.session else {
169
+ promise.reject(AcsMeetingError("No active meeting"))
170
+ return
171
+ }
172
+ session.setVideoEnabled(enabled) { result in
173
+ switch result {
174
+ case let .success(state): promise.resolve(state)
175
+ case let .failure(error): promise.reject(error)
176
+ }
177
+ }
178
+ }
179
+
166
180
  AsyncFunction("setAudioSource") { (source: String, promise: Promise) in
167
181
  guard let session = self.session else {
168
182
  promise.resolve(["state": "idle", "muted": false, "audioSource": source]); return
@@ -235,16 +249,20 @@ final class AcsMeetingSession {
235
249
  private lazy var applier = AudioPolicyApplier(controller: controller, scheduler: scheduler) { NSLog("ACS-SPIKE \($0)") }
236
250
  private var phase = "idle"
237
251
  private var muted = false
252
+ /// Whether ACS is sending `videoStream`. Only `setVideoEnabled` turns it off; every call starts on.
253
+ private var videoEnabled = true
254
+ private var videoStream: VirtualOutgoingVideoStream?
238
255
  private var meetingUrl: String?
239
256
  private var lastError: String?
240
257
  private var callEndReason: (code: Int, subcode: Int)?
241
258
  private var callClient: CallClient?
242
- private var callAgent: CallAgent?
243
- private var call: Call?
259
+ private var callAgent: CommonCallAgent?
260
+ private var call: CommonCall?
244
261
  private var media: DecodedGlassesMediaSource?
245
262
  private var sourceConfig = SourceConfig(url: "")
246
- private var preparedAgent: CallAgent?
263
+ private var preparedAgent: CommonCallAgent?
247
264
  private var preparedClient: CallClient?
265
+ private var preparedIdentityMode: String?
248
266
  private var preparedToken: String?
249
267
  private var pendingJoin: ((Result<[String: Any], Error>) -> Void)?
250
268
  private var pendingPrepare: ((Result<[String: Any], Error>) -> Void)?
@@ -281,6 +299,11 @@ final class AcsMeetingSession {
281
299
  onMuteChange: { [weak self] call in self?.handleCallMuteChange(call) },
282
300
  onRosterChange: { [weak self] call in self?.refreshRoster(call) }
283
301
  )
302
+ private lazy var teamsCallDelegateProxy = AcsTeamsCallDelegateProxy(
303
+ onStateChange: { [weak self] call in self?.handleCallStateChange(call) },
304
+ onMuteChange: { [weak self] call in self?.handleCallMuteChange(call) },
305
+ onRosterChange: { [weak self] call in self?.refreshRoster(call) }
306
+ )
284
307
  private var remoteParticipants: [ObjectIdentifier: RemoteParticipant] = [:]
285
308
  private lazy var participantDelegateProxy = AcsParticipantDelegateProxy { [weak self] participant in
286
309
  self?.queue.async {
@@ -308,6 +331,7 @@ final class AcsMeetingSession {
308
331
  var result: [String: Any] = [
309
332
  "state": phase,
310
333
  "muted": muted,
334
+ "videoEnabled": videoEnabled,
311
335
  "provider": "acs-teams",
312
336
  "audioSource": audioSource,
313
337
  "activeStream": controller.readActive().rawValue,
@@ -342,7 +366,7 @@ final class AcsMeetingSession {
342
366
  return result
343
367
  }
344
368
 
345
- func prepareAgent(token: String, displayName: String?, completion: @escaping (Result<[String: Any], Error>) -> Void) {
369
+ func prepareAgent(token: String, displayName: String?, identityMode: String = "guest", completion: @escaping (Result<[String: Any], Error>) -> Void) {
346
370
  queue.async {
347
371
  guard self.cleanup.wait(timeout: .now()) == .success, self.pendingPrepare == nil, self.pendingJoin == nil, self.call == nil, self.media == nil else {
348
372
  completion(.failure(AcsMeetingError("Previous call cleanup is still pending"))); return
@@ -356,7 +380,7 @@ final class AcsMeetingSession {
356
380
  let credential = try CommunicationTokenCredential(token: token)
357
381
  let options = CallAgentOptions()
358
382
  options.displayName = displayName ?? "Mentra Call"
359
- client.createCallAgent(userCredential: credential, options: options) { agent, error in
383
+ createMeetingAgent(client: client, credential: credential, guestOptions: options, identityMode: identityMode) { agent, error in
360
384
  self.queue.async {
361
385
  guard self.joinGeneration == generation, self.pendingPrepare != nil else { agent?.dispose(); return }
362
386
  let reply = self.pendingPrepare
@@ -364,6 +388,7 @@ final class AcsMeetingSession {
364
388
  if let agent, error == nil {
365
389
  self.preparedAgent = agent
366
390
  self.preparedToken = token
391
+ self.preparedIdentityMode = identityMode
367
392
  reply?(.success(self.snapshotLocked()))
368
393
  } else {
369
394
  agent?.dispose()
@@ -384,7 +409,7 @@ final class AcsMeetingSession {
384
409
  }
385
410
  }
386
411
 
387
- func join(token: String, meetingUrl: String, sourceConfig: SourceConfig, displayName: String?, dumpWav: Bool,
412
+ func join(token: String, identityMode: String = "guest", meetingUrl: String, sourceConfig: SourceConfig, displayName: String?, dumpWav: Bool,
388
413
  audioSource: String = "glasses", video: AcsOutgoingVideo = .hd,
389
414
  completion: @escaping (Result<[String: Any], Error>) -> Void)
390
415
  {
@@ -392,7 +417,7 @@ final class AcsMeetingSession {
392
417
  guard self.cleanup.wait(timeout: .now()) == .success, self.pendingPrepare == nil, self.pendingJoin == nil, self.call == nil, self.media == nil else {
393
418
  completion(.failure(AcsMeetingError("Previous call cleanup is still pending"))); return
394
419
  }
395
- let prepared = self.preparedToken == token ? self.preparedAgent : nil
420
+ let prepared = self.preparedToken == token && self.preparedIdentityMode == identityMode ? self.preparedAgent : nil
396
421
  let preparedClient = self.preparedClient
397
422
  if prepared != nil { self.preparedAgent = nil; self.preparedClient = nil; self.preparedToken = nil }
398
423
  self.leaveLocked(emitIdle: false)
@@ -404,7 +429,7 @@ final class AcsMeetingSession {
404
429
  self.lastError = nil
405
430
  self.callEndReason = nil
406
431
  self.emit("connecting")
407
- let useAgent: (CallAgent) -> Void = { agent in
432
+ let useAgent: (CommonCallAgent) -> Void = { agent in
408
433
  do { try self.joinWithAgentLocked(agent, generation: generation, meetingUrl: meetingUrl,
409
434
  sourceConfig: sourceConfig, dumpWav: dumpWav, video: video) } catch { self.failJoinLocked(error, generation: generation) }
410
435
  }
@@ -421,7 +446,7 @@ final class AcsMeetingSession {
421
446
  options.displayName = displayName ?? "Mentra Call"
422
447
  let agentStartedAt = ProcessInfo.processInfo.systemUptime
423
448
  NSLog("ACS-SPIKE iOS agent creation started generation=\(generation)")
424
- client.createCallAgent(userCredential: credential, options: options) { agent, error in
449
+ createMeetingAgent(client: client, credential: credential, guestOptions: options, identityMode: identityMode) { agent, error in
425
450
  self.queue.async {
426
451
  let elapsedMs = Int((ProcessInfo.processInfo.systemUptime - agentStartedAt) * 1000)
427
452
  NSLog("ACS-SPIKE iOS agent creation completed generation=\(generation) elapsedMs=\(elapsedMs) success=\(agent != nil && error == nil)")
@@ -444,7 +469,7 @@ final class AcsMeetingSession {
444
469
  }
445
470
 
446
471
  private func joinWithAgentLocked(
447
- _ agent: CallAgent,
472
+ _ agent: CommonCallAgent,
448
473
  generation: UInt64,
449
474
  meetingUrl: String,
450
475
  sourceConfig: SourceConfig,
@@ -463,6 +488,7 @@ final class AcsMeetingSession {
463
488
  let videoOptions = RawOutgoingVideoStreamOptions()
464
489
  videoOptions.formats = [videoFormat]
465
490
  let videoStream = VirtualOutgoingVideoStream(videoStreamOptions: videoOptions)
491
+ self.videoStream = videoStream
466
492
  frameSender = AcsFrameSender()
467
493
  frameSender.attach(videoStream)
468
494
 
@@ -523,14 +549,14 @@ final class AcsMeetingSession {
523
549
  let locator = TeamsMeetingLinkLocator(meetingLink: meetingUrl)
524
550
  // Cancellation reserves cleanup and retains the agent until the late join result
525
551
  // can be hung up. The retirement deadline still handles an SDK callback that is lost.
526
- let joinRetirement = CallJoinRetirement<Call>(group: cleanup, queue: queue, dispose: { agent.dispose() }) { call, finished in
552
+ let joinRetirement = CallJoinRetirement<CommonCall>(group: cleanup, queue: queue, dispose: { agent.dispose() }) { call, finished in
527
553
  call.hangUp(options: nil) { error in
528
554
  if let error { NSLog("ACS-SPIKE cancelled join hangUp failed: \(error)") }
529
555
  finished()
530
556
  }
531
557
  }
532
558
  cancelPendingCallJoin = { joinRetirement.cancel() }
533
- agent.join(with: locator, joinCallOptions: joinOptions) { call, error in
559
+ joinMeeting(agent: agent, locator: locator, guestOptions: joinOptions) { call, error in
534
560
  self.queue.async {
535
561
  guard joinRetirement.receive(call) else { return }
536
562
  guard self.callAgent === agent, self.joinGeneration == generation else {
@@ -559,7 +585,7 @@ final class AcsMeetingSession {
559
585
  }
560
586
 
561
587
  private func finishJoinLocked(
562
- _ call: Call,
588
+ _ call: CommonCall,
563
589
  generation: UInt64,
564
590
  sourceConfig: SourceConfig,
565
591
  dumpWav: Bool,
@@ -571,7 +597,8 @@ final class AcsMeetingSession {
571
597
  return
572
598
  }
573
599
  self.call = call
574
- call.delegate = callDelegateProxy
600
+ (call as? Call)?.delegate = callDelegateProxy
601
+ (call as? TeamsCall)?.delegate = teamsCallDelegateProxy
575
602
  refreshRosterLocked(call)
576
603
  attachCapabilities(call)
577
604
 
@@ -581,7 +608,12 @@ final class AcsMeetingSession {
581
608
  self?.feedOutgoingPcm(pcm, sampleRate: rate, channels: channels, generation: generation)
582
609
  }
583
610
  let source: DecodedGlassesMediaSource = sourceConfig.kind == .softap ? LocalWhipIngestSource() : WhepVideoSource()
584
- source.onFrame = { [frameSender] buffer in frameSender.send(buffer) }
611
+ source.onFrame = { [frameSender] buffer in
612
+ // Preview first, deliberately: the tap must see frames the ACS sender's pacing and
613
+ // readiness gates would otherwise hide, and it cannot delay or break this call.
614
+ DecodedFrameTap.shared.offer(buffer)
615
+ if frameSender.send(buffer) { DecodedFrameTap.shared.recordAcsSend() }
616
+ }
585
617
  source.onPcm = { [weak self] pcm, rate, channels in
586
618
  self?.feedOutgoingPcm(pcm, sampleRate: rate, channels: channels, generation: generation)
587
619
  }
@@ -717,6 +749,46 @@ final class AcsMeetingSession {
717
749
  }
718
750
  }
719
751
 
752
+ /// Stop or resume the camera Teams receives without leaving the call. The glasses source, the
753
+ /// preview tap and the WHEP/WHIP transport keep running; `AcsFrameSender` drops frames while
754
+ /// ACS reports the stream stopped. `videoEnabled` only moves once ACS accepts the change.
755
+ func setVideoEnabled(_ next: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) {
756
+ queue.async {
757
+ guard let call = self.call, let stream = self.videoStream else {
758
+ completion(.failure(AcsMeetingError("No active meeting")))
759
+ return
760
+ }
761
+ guard self.videoEnabled != next else {
762
+ completion(.success(self.snapshotLocked()))
763
+ return
764
+ }
765
+ let generation = self.joinGeneration
766
+ let finished: (Error?) -> Void = { error in
767
+ self.queue.async {
768
+ guard self.joinGeneration == generation, self.call === call else {
769
+ completion(.failure(AcsMeetingError("The meeting ended before the camera changed")))
770
+ return
771
+ }
772
+ if let error {
773
+ NSLog("ACS-SPIKE setVideoEnabled=\(next) failed: \(error)")
774
+ completion(.failure(error))
775
+ return
776
+ }
777
+ NSLog("ACS-SPIKE setVideoEnabled=\(next)")
778
+ self.videoEnabled = next
779
+ let snapshot = self.snapshotLocked()
780
+ self.onState(snapshot)
781
+ completion(.success(snapshot))
782
+ }
783
+ }
784
+ if next {
785
+ call.startVideo(stream: stream, completionHandler: finished)
786
+ } else {
787
+ call.stopVideo(stream: stream, completionHandler: finished)
788
+ }
789
+ }
790
+ }
791
+
720
792
  func setAudioSource(_ source: String, completion: @escaping ([String: Any]) -> Void) {
721
793
  queue.async {
722
794
  if AcsAudioPolicy.parseSource(source) == nil {
@@ -845,7 +917,7 @@ final class AcsMeetingSession {
845
917
  promoted to presenter mid-call), and an End button that never turns on is the same bug as one
846
918
  that lies about what it does.
847
919
  */
848
- private func attachCapabilities(_ call: Call) {
920
+ private func attachCapabilities(_ call: CommonCall) {
849
921
  let feature = call.feature(Features.capabilities)
850
922
  capabilitiesFeature = feature
851
923
  feature.delegate = capabilitiesDelegateProxy
@@ -976,7 +1048,8 @@ final class AcsMeetingSession {
976
1048
  let leavingAgent = callAgent
977
1049
  let cancelJoin = cancelPendingCallJoin
978
1050
  cancelPendingCallJoin = nil
979
- leavingCall?.delegate = nil
1051
+ (leavingCall as? Call)?.delegate = nil
1052
+ (leavingCall as? TeamsCall)?.delegate = nil
980
1053
  if let leavingCall {
981
1054
  // CallAgent.dispose releases all local SDK resources. The retirement deadline
982
1055
  // also disposes the agent if hangUp loses its callback, before opening the barrier.
@@ -1004,6 +1077,8 @@ final class AcsMeetingSession {
1004
1077
  pcmBridge = nil
1005
1078
  outgoingReady = false
1006
1079
  muted = false
1080
+ videoEnabled = true
1081
+ videoStream = nil
1007
1082
  audioSource = "glasses"
1008
1083
  lastSafety = .degraded
1009
1084
  meetingUrl = nil
@@ -1017,7 +1092,7 @@ final class AcsMeetingSession {
1017
1092
  }
1018
1093
  }
1019
1094
 
1020
- fileprivate func currentCall() -> Call? {
1095
+ fileprivate func currentCall() -> CommonCall? {
1021
1096
  call
1022
1097
  }
1023
1098
 
@@ -1029,14 +1104,14 @@ final class AcsMeetingSession {
1029
1104
  phoneMic.setEnabled(enabled)
1030
1105
  }
1031
1106
 
1032
- private func handleCallStateChange(_ changedCall: Call) {
1107
+ private func handleCallStateChange(_ changedCall: CommonCall) {
1033
1108
  queue.async {
1034
1109
  guard self.call === changedCall, self.lastError == nil else { return }
1035
1110
  self.refreshCallStateLocked(changedCall)
1036
1111
  }
1037
1112
  }
1038
1113
 
1039
- private func refreshCallStateLocked(_ changedCall: Call) {
1114
+ private func refreshCallStateLocked(_ changedCall: CommonCall) {
1040
1115
  switch changedCall.state {
1041
1116
  case .connecting: emit("connecting")
1042
1117
  case .inLobby: emit("lobby")
@@ -1071,14 +1146,14 @@ final class AcsMeetingSession {
1071
1146
  }
1072
1147
  }
1073
1148
 
1074
- private func handleCallMuteChange(_ changedCall: Call) {
1149
+ private func handleCallMuteChange(_ changedCall: CommonCall) {
1075
1150
  queue.async {
1076
1151
  guard self.call === changedCall, self.lastError == nil else { return }
1077
1152
  self.applyAudioPolicyOnQueue("outgoing-audio-state")
1078
1153
  }
1079
1154
  }
1080
1155
 
1081
- private func refreshRoster(_ changedCall: Call) {
1156
+ private func refreshRoster(_ changedCall: CommonCall) {
1082
1157
  queue.async {
1083
1158
  guard self.call === changedCall else { return }
1084
1159
  self.refreshRosterLocked(changedCall)
@@ -1086,7 +1161,7 @@ final class AcsMeetingSession {
1086
1161
  }
1087
1162
  }
1088
1163
 
1089
- private func refreshRosterLocked(_ changedCall: Call) {
1164
+ private func refreshRosterLocked(_ changedCall: CommonCall) {
1090
1165
  dispatchPrecondition(condition: .onQueue(queue))
1091
1166
  let current = Dictionary(uniqueKeysWithValues: changedCall.remoteParticipants.map {
1092
1167
  (ObjectIdentifier($0), $0)
@@ -1209,12 +1284,12 @@ final class SessionAudioController: AudioStreamController {
1209
1284
  }
1210
1285
 
1211
1286
  private final class AcsCallDelegateProxy: NSObject, CallDelegate {
1212
- private let onStateChange: (Call) -> Void
1213
- private let onMuteChange: (Call) -> Void
1214
- private let onRosterChange: (Call) -> Void
1287
+ private let onStateChange: (CommonCall) -> Void
1288
+ private let onMuteChange: (CommonCall) -> Void
1289
+ private let onRosterChange: (CommonCall) -> Void
1215
1290
 
1216
- init(onStateChange: @escaping (Call) -> Void, onMuteChange: @escaping (Call) -> Void,
1217
- onRosterChange: @escaping (Call) -> Void)
1291
+ init(onStateChange: @escaping (CommonCall) -> Void, onMuteChange: @escaping (CommonCall) -> Void,
1292
+ onRosterChange: @escaping (CommonCall) -> Void)
1218
1293
  {
1219
1294
  self.onStateChange = onStateChange
1220
1295
  self.onMuteChange = onMuteChange
@@ -1234,6 +1309,32 @@ private final class AcsCallDelegateProxy: NSObject, CallDelegate {
1234
1309
  }
1235
1310
  }
1236
1311
 
1312
+ private final class AcsTeamsCallDelegateProxy: NSObject, TeamsCallDelegate {
1313
+ private let onStateChange: (CommonCall) -> Void
1314
+ private let onMuteChange: (CommonCall) -> Void
1315
+ private let onRosterChange: (CommonCall) -> Void
1316
+
1317
+ init(onStateChange: @escaping (CommonCall) -> Void, onMuteChange: @escaping (CommonCall) -> Void,
1318
+ onRosterChange: @escaping (CommonCall) -> Void)
1319
+ {
1320
+ self.onStateChange = onStateChange
1321
+ self.onMuteChange = onMuteChange
1322
+ self.onRosterChange = onRosterChange
1323
+ }
1324
+
1325
+ func teamsCall(_ call: TeamsCall, didChangeState _: PropertyChangedEventArgs) {
1326
+ onStateChange(call)
1327
+ }
1328
+
1329
+ func teamsCall(_ call: TeamsCall, didUpdateOutgoingAudioState _: PropertyChangedEventArgs) {
1330
+ onMuteChange(call)
1331
+ }
1332
+
1333
+ func teamsCall(_ call: TeamsCall, didUpdateRemoteParticipant _: ParticipantsUpdatedEventArgs) {
1334
+ onRosterChange(call)
1335
+ }
1336
+ }
1337
+
1237
1338
  private final class AcsParticipantDelegateProxy: NSObject, RemoteParticipantDelegate {
1238
1339
  private let onChanged: (RemoteParticipant) -> Void
1239
1340
 
@@ -1337,3 +1438,36 @@ private func parseAcsOutgoingVideo(_ raw: Any?) throws -> AcsOutgoingVideo {
1337
1438
  }
1338
1439
  return AcsOutgoingVideo(width: width, height: height, fps: fps, maxBitrateBps: bitrate)
1339
1440
  }
1441
+
1442
+ /// Both identity modes use the same raw media and lifecycle code. Only agent creation and
1443
+ /// join options differ; the authenticated Teams display name is supplied by Microsoft.
1444
+ private func createMeetingAgent(client: CallClient, credential: CommunicationTokenCredential,
1445
+ guestOptions: CallAgentOptions, identityMode: String,
1446
+ completion: @escaping (CommonCallAgent?, Error?) -> Void)
1447
+ {
1448
+ switch identityMode {
1449
+ case "guest":
1450
+ client.createCallAgent(userCredential: credential, options: guestOptions) { completion($0, $1) }
1451
+ case "teams-user":
1452
+ client.createTeamsCallAgent(userCredential: credential, options: TeamsCallAgentOptions()) { completion($0, $1) }
1453
+ default:
1454
+ completion(nil, AcsMeetingError("Unsupported meeting identity mode"))
1455
+ }
1456
+ }
1457
+
1458
+ private func joinMeeting(agent: CommonCallAgent, locator: TeamsMeetingLinkLocator,
1459
+ guestOptions: JoinCallOptions,
1460
+ completion: @escaping (CommonCall?, Error?) -> Void)
1461
+ {
1462
+ if let teams = agent as? TeamsCallAgent {
1463
+ let options = JoinTeamsCallOptions()
1464
+ options.outgoingVideoOptions = guestOptions.outgoingVideoOptions
1465
+ options.outgoingAudioOptions = guestOptions.outgoingAudioOptions
1466
+ options.incomingAudioOptions = guestOptions.incomingAudioOptions
1467
+ teams.join(with: locator, joinTeamsCallOptions: options) { completion($0, $1) }
1468
+ } else if let guest = agent as? CallAgent {
1469
+ guest.join(with: locator, joinCallOptions: guestOptions) { completion($0, $1) }
1470
+ } else {
1471
+ completion(nil, AcsMeetingError("Unsupported meeting agent"))
1472
+ }
1473
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/acs-meeting",
3
- "version": "3.2.1-dev.311",
3
+ "version": "3.2.1",
4
4
  "description": "MentraOS native ACS Teams meeting module (WHEP decode → ACS raw media)",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@expo/config-plugins": "~55.0.10",
35
- "@mentra/glasses-media": "3.2.1-dev.311"
35
+ "@mentra/glasses-media": "3.2.1"
36
36
  },
37
37
  "devDependencies": {
38
38
  "expo-module-scripts": "^55.0.2",
@@ -23,6 +23,8 @@ export type AcsMeetingParticipant = {
23
23
  export type AcsMeetingState = {
24
24
  state: MeetingPhase
25
25
  muted: boolean
26
+ /** Whether ACS is sending the glasses camera. Resets to true on every join. */
27
+ videoEnabled?: boolean
26
28
  error?: string
27
29
  meetingUrl?: string
28
30
  provider?: "acs-teams"
@@ -51,6 +53,7 @@ export type AcsOutgoingVideo = {
51
53
  export type AcsMeetingJoinOptions = {
52
54
  meetingUrl: string
53
55
  token: string
56
+ identityMode?: "guest" | "teams-user"
54
57
  whepUrl?: string
55
58
  videoSource?: {type: "whep"; url: string} | {type: "softap"; bindAddress: string}
56
59
  displayName?: string
@@ -3,9 +3,14 @@ import {NativeModule, requireNativeModule} from "expo"
3
3
  import type {AcsMeetingJoinOptions, AcsMeetingModuleEvents, AcsMeetingState} from "./AcsMeeting.types"
4
4
 
5
5
  declare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents> {
6
+ supportsTeamsIdentity(): boolean
6
7
  join(options: AcsMeetingJoinOptions): Promise<AcsMeetingState>
7
8
  /** Sign in to ACS before SoftAP so Teams is not resolved through glasses DNS. */
8
- prepareAgent(options: {token: string; displayName?: string}): Promise<AcsMeetingState>
9
+ prepareAgent(options: {
10
+ token: string
11
+ displayName?: string
12
+ identityMode?: "guest" | "teams-user"
13
+ }): Promise<AcsMeetingState>
9
14
  leave(): Promise<void>
10
15
  admitParticipant?(participantId: string): Promise<void>
11
16
  /**
@@ -14,6 +19,11 @@ declare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents
14
19
  */
15
20
  leaveAndAwait(options: {timeoutMs: number}): Promise<{completed: boolean}>
16
21
  setMuted(muted: boolean): Promise<AcsMeetingState>
22
+ /**
23
+ * Stop or resume the outgoing video stream (ACS `stopVideo`/`startVideo`). The call, the glasses
24
+ * media source and the preview tap stay up; frames are dropped before ACS while stopped.
25
+ */
26
+ setVideoEnabled(enabled: boolean): Promise<AcsMeetingState>
17
27
  setAudioSource(source: "glasses" | "phone"): Promise<AcsMeetingState>
18
28
  updateVideoSource(whepUrl: string): Promise<void>
19
29
  /** Force a WHEP rebuild on the current URL (phone changed networks). */
@@ -8,7 +8,11 @@ export default {
8
8
  async join(_options: AcsMeetingJoinOptions): Promise<AcsMeetingState> {
9
9
  unavailable()
10
10
  },
11
- async prepareAgent(_options: {token: string; displayName?: string}): Promise<AcsMeetingState> {
11
+ async prepareAgent(_options: {
12
+ token: string
13
+ displayName?: string
14
+ identityMode?: "guest" | "teams-user"
15
+ }): Promise<AcsMeetingState> {
12
16
  unavailable()
13
17
  },
14
18
  async leave(): Promise<void> {
@@ -20,6 +24,9 @@ export default {
20
24
  async setMuted(_muted: boolean): Promise<AcsMeetingState> {
21
25
  unavailable()
22
26
  },
27
+ async setVideoEnabled(_enabled: boolean): Promise<AcsMeetingState> {
28
+ unavailable()
29
+ },
23
30
  async setAudioSource(_source: "glasses" | "phone"): Promise<AcsMeetingState> {
24
31
  unavailable()
25
32
  },