@mentra/acs-meeting 3.3.0-dev.328 → 3.3.0-dev.329

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 |
@@ -429,6 +429,18 @@ class AcsMeetingModule : Module() {
429
429
  session?.setMuted(muted) ?: mapOf("state" to "idle", "muted" to muted)
430
430
  }
431
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
+
432
444
  AsyncFunction("setAudioSource") { source: String ->
433
445
  session?.setAudioSource(source) ?: mapOf("state" to "idle", "muted" to false, "audioSource" to source)
434
446
  }
@@ -74,6 +74,7 @@ import com.mentra.glassesmedia.source.OutgoingRateArm
74
74
  import com.mentra.glassesmedia.source.PixelFormatArm
75
75
  import com.mentra.glassesmedia.source.GlassesMediaController
76
76
  import com.mentra.glassesmedia.network.ScopedSoftApNetwork
77
+ import com.mentra.glassesmedia.source.DecodedFrameTap
77
78
  import com.mentra.glassesmedia.source.GlassesMediaSourceFactory
78
79
  import com.mentra.glassesmedia.source.LocalWhipIngestSource
79
80
  import com.mentra.acsmeeting.source.MeetingVideoSourceSpec
@@ -210,6 +211,8 @@ class AcsMeetingSession(
210
211
  private var localOut: LocalOutgoingAudioStream? = null
211
212
  private var audioIn: RawIncomingAudioStream? = null
212
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
213
216
  @Volatile private var meetingUrl: String? = null
214
217
  @Volatile private var phase = "idle"
215
218
  @Volatile private var lastError: String? = null
@@ -285,6 +288,7 @@ class AcsMeetingSession(
285
288
  val result = mutableMapOf<String, Any>(
286
289
  "state" to phase,
287
290
  "muted" to muted.get(),
291
+ "videoEnabled" to videoEnabled,
288
292
  "provider" to "acs-teams",
289
293
  "audioSource" to audioSource,
290
294
  "activeStream" to controller.readActive().name.lowercase(),
@@ -641,7 +645,10 @@ class AcsMeetingSession(
641
645
  bindIngestUnpinned {
642
646
  media.attach(
643
647
  video = { planes ->
644
- 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()
645
652
  },
646
653
  pcm = { pcm, rate, channels -> feedOutgoingPcm(pcm, rate, channels) },
647
654
  config = videoSource.toConfig(),
@@ -668,7 +675,8 @@ class AcsMeetingSession(
668
675
  if (videoSource !is MeetingVideoSourceSpec.SoftAp) {
669
676
  media.attach(
670
677
  video = { planes ->
671
- frameSender.sendPlanes(planes)
678
+ DecodedFrameTap.offer(planes)
679
+ if (frameSender.sendPlanes(planes)) DecodedFrameTap.recordAcsSend()
672
680
  },
673
681
  pcm = { pcm, rate, channels -> feedOutgoingPcm(pcm, rate, channels) },
674
682
  config = if (synthetic) SourceConfig("", SourceKind.DIRECT) else videoSource.toConfig(),
@@ -900,6 +908,45 @@ class AcsMeetingSession(
900
908
  return snap
901
909
  }
902
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
+
903
950
  fun setAudioSource(source: String): Map<String, Any> {
904
951
  val parsed = AcsAudioPolicy.parseSource(source)
905
952
  if (parsed == null) {
@@ -2021,6 +2068,7 @@ class AcsMeetingSession(
2021
2068
  localOut = null
2022
2069
  audioIn = null
2023
2070
  videoOut = null
2071
+ videoEnabled = true
2024
2072
  outgoingReady.set(false)
2025
2073
  muted.set(false)
2026
2074
  hangUpForEveryone = CapabilityStatus()
@@ -2097,6 +2145,7 @@ class AcsMeetingSession(
2097
2145
  private const val TAG = "ACS-SPIKE"
2098
2146
  const val GLASSES_REQUIRES_UNMUTED_TRANSPORT = true
2099
2147
  private const val ROSTER_COALESCE_MS = 150L
2148
+ private const val VIDEO_TOGGLE_TIMEOUT_MS = 10_000L
2100
2149
  private const val MEDIA_RESTART_BASE_MS = 1_000L
2101
2150
  private const val MEDIA_RESTART_MAX_MS = 10_000L
2102
2151
 
@@ -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";
@@ -1 +1 @@
1
- {"version":3,"file":"AcsMeeting.types.d.ts","sourceRoot":"","sources":["../src/AcsMeeting.types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AcsMeeting.types.d.ts","sourceRoot":"","sources":["../src/AcsMeeting.types.ts"],"names":[],"mappings":"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"}
@@ -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 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
+ {"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"]}
@@ -21,6 +21,11 @@ declare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents
21
21
  completed: boolean;
22
22
  }>;
23
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>;
24
29
  setAudioSource(source: "glasses" | "phone"): Promise<AcsMeetingState>;
25
30
  updateVideoSource(whepUrl: string): Promise<void>;
26
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,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,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;AA4CtD,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 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"]}
@@ -13,6 +13,7 @@ declare const _default: {
13
13
  completed: boolean;
14
14
  }>;
15
15
  setMuted(_muted: boolean): Promise<AcsMeetingState>;
16
+ setVideoEnabled(_enabled: boolean): Promise<AcsMeetingState>;
16
17
  setAudioSource(_source: "glasses" | "phone"): Promise<AcsMeetingState>;
17
18
  updateVideoSource(_whepUrl: string): Promise<void>;
18
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;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;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;;;;;;AAvC5C,wBA8CC"}
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,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,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 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() {
@@ -164,6 +164,19 @@ public class AcsMeetingModule: Module {
164
164
  session.setMuted(muted) { promise.resolve($0) }
165
165
  }
166
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
+
167
180
  AsyncFunction("setAudioSource") { (source: String, promise: Promise) in
168
181
  guard let session = self.session else {
169
182
  promise.resolve(["state": "idle", "muted": false, "audioSource": source]); return
@@ -236,6 +249,9 @@ final class AcsMeetingSession {
236
249
  private lazy var applier = AudioPolicyApplier(controller: controller, scheduler: scheduler) { NSLog("ACS-SPIKE \($0)") }
237
250
  private var phase = "idle"
238
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?
239
255
  private var meetingUrl: String?
240
256
  private var lastError: String?
241
257
  private var callEndReason: (code: Int, subcode: Int)?
@@ -315,6 +331,7 @@ final class AcsMeetingSession {
315
331
  var result: [String: Any] = [
316
332
  "state": phase,
317
333
  "muted": muted,
334
+ "videoEnabled": videoEnabled,
318
335
  "provider": "acs-teams",
319
336
  "audioSource": audioSource,
320
337
  "activeStream": controller.readActive().rawValue,
@@ -471,6 +488,7 @@ final class AcsMeetingSession {
471
488
  let videoOptions = RawOutgoingVideoStreamOptions()
472
489
  videoOptions.formats = [videoFormat]
473
490
  let videoStream = VirtualOutgoingVideoStream(videoStreamOptions: videoOptions)
491
+ self.videoStream = videoStream
474
492
  frameSender = AcsFrameSender()
475
493
  frameSender.attach(videoStream)
476
494
 
@@ -590,7 +608,12 @@ final class AcsMeetingSession {
590
608
  self?.feedOutgoingPcm(pcm, sampleRate: rate, channels: channels, generation: generation)
591
609
  }
592
610
  let source: DecodedGlassesMediaSource = sourceConfig.kind == .softap ? LocalWhipIngestSource() : WhepVideoSource()
593
- 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
+ }
594
617
  source.onPcm = { [weak self] pcm, rate, channels in
595
618
  self?.feedOutgoingPcm(pcm, sampleRate: rate, channels: channels, generation: generation)
596
619
  }
@@ -726,6 +749,46 @@ final class AcsMeetingSession {
726
749
  }
727
750
  }
728
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
+
729
792
  func setAudioSource(_ source: String, completion: @escaping ([String: Any]) -> Void) {
730
793
  queue.async {
731
794
  if AcsAudioPolicy.parseSource(source) == nil {
@@ -1014,6 +1077,8 @@ final class AcsMeetingSession {
1014
1077
  pcmBridge = nil
1015
1078
  outgoingReady = false
1016
1079
  muted = false
1080
+ videoEnabled = true
1081
+ videoStream = nil
1017
1082
  audioSource = "glasses"
1018
1083
  lastSafety = .degraded
1019
1084
  meetingUrl = nil
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/acs-meeting",
3
- "version": "3.3.0-dev.328",
3
+ "version": "3.3.0-dev.329",
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.3.0-dev.328"
35
+ "@mentra/glasses-media": "3.3.0-dev.329"
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"
@@ -19,6 +19,11 @@ declare class AcsMeetingNativeModule extends NativeModule<AcsMeetingModuleEvents
19
19
  */
20
20
  leaveAndAwait(options: {timeoutMs: number}): Promise<{completed: boolean}>
21
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>
22
27
  setAudioSource(source: "glasses" | "phone"): Promise<AcsMeetingState>
23
28
  updateVideoSource(whepUrl: string): Promise<void>
24
29
  /** Force a WHEP rebuild on the current URL (phone changed networks). */
@@ -24,6 +24,9 @@ export default {
24
24
  async setMuted(_muted: boolean): Promise<AcsMeetingState> {
25
25
  unavailable()
26
26
  },
27
+ async setVideoEnabled(_enabled: boolean): Promise<AcsMeetingState> {
28
+ unavailable()
29
+ },
27
30
  async setAudioSource(_source: "glasses" | "phone"): Promise<AcsMeetingState> {
28
31
  unavailable()
29
32
  },