@q9labsai/chalk-client 5.2.5 → 5.3.0

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.
@@ -6,11 +6,11 @@ import {
6
6
  describeMediaCaptureError,
7
7
  grantedMediaFromStream,
8
8
  markMediaFailure
9
- } from "./chunk-FD25AMLG.js";
9
+ } from "./chunk-3TGVTP6Z.js";
10
10
  import {
11
11
  AccessGrantError,
12
12
  requireParsedAccessGrant
13
- } from "./chunk-UMAE5GCV.js";
13
+ } from "./chunk-GOSWSXTH.js";
14
14
  import {
15
15
  ChatAttachmentMimeTypeSchema,
16
16
  ChatReactionSetFrameSchema,
@@ -24,7 +24,7 @@ import {
24
24
  formatSyncConnectDiagnostic,
25
25
  scheduleSyncDialDeadline,
26
26
  syncConnectFailureCode
27
- } from "./chunk-5YPQNMJE.js";
27
+ } from "./chunk-XSB2GDRW.js";
28
28
 
29
29
  // src/access/manager.ts
30
30
  import { Clock, Context, Data, Deferred, Effect, Layer, SynchronizedRef } from "effect";
@@ -2699,6 +2699,7 @@ function createSFUConnectionMediaClient(client) {
2699
2699
  function bindConnectionMediaClient(client, restart) {
2700
2700
  return {
2701
2701
  setLocalPublicationTarget: client.setLocalPublicationTarget.bind(client),
2702
+ ...client.pauseLocalCapture ? { pauseLocalCapture: client.pauseLocalCapture.bind(client) } : {},
2702
2703
  ...client.setLocalSourceIntent ? { setLocalSourceIntent: client.setLocalSourceIntent.bind(client) } : {},
2703
2704
  ...client.closeForcedLocalPublication ? { closeForcedLocalPublication: client.closeForcedLocalPublication.bind(client) } : {},
2704
2705
  observeLocalPublications: client.observeLocalPublications.bind(client),
@@ -2852,7 +2853,7 @@ var ConnectionDiagnostics = class {
2852
2853
  // src/connection/lifecycle.ts
2853
2854
  var MEDIA_RECOVERY_TIMEOUT_MS = 6e4;
2854
2855
  var START_TIMEOUT_MS = 3e4;
2855
- var LEAVE_TIMEOUT_MS = 5e3;
2856
+ var LEAVE_TIMEOUT_MS = 4e4;
2856
2857
  var RECOVERY_BUDGET_MS = MEDIA_RECOVERY_TIMEOUT_MS;
2857
2858
  var MAX_RECOVERY_ATTEMPTS = 3;
2858
2859
  var REFRESH_RETRY_MS = 5e3;
@@ -2998,7 +2999,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2998
2999
  yield* publish();
2999
3000
  });
3000
3001
  const enqueue = (effect, join = false) => Effect2.gen(function* () {
3001
- if (model.closed) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed"));
3002
+ if (model.closed) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed. Rejoin the call."));
3002
3003
  const deferred = yield* Deferred2.make();
3003
3004
  yield* Queue.offer(queue, { effect, deferred, join });
3004
3005
  return yield* Deferred2.await(deferred);
@@ -3290,6 +3291,12 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3290
3291
  if (model.failure) return yield* Effect2.fail(lifecycleFailure("leave_unconfirmed", false, model.failure.message));
3291
3292
  return;
3292
3293
  }
3294
+ yield* Effect2.sync(() => {
3295
+ for (const { track } of model.media?.getSnapshot().localTracks ?? []) {
3296
+ track.enabled = false;
3297
+ track.stop();
3298
+ }
3299
+ });
3293
3300
  yield* transition("leaving");
3294
3301
  const confirmed = yield* leaveIsConfirmed();
3295
3302
  yield* stopPorts();
@@ -3328,7 +3335,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3328
3335
  yield* scheduleRefresh();
3329
3336
  })
3330
3337
  );
3331
- const requireOpen = Effect2.suspend(() => model.closed ? Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed")) : Effect2.void);
3338
+ const requireOpen = Effect2.suspend(() => model.closed ? Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed. Rejoin the call.")) : Effect2.void);
3332
3339
  const withFreshAccess = (operation) => Effect2.gen(function* () {
3333
3340
  yield* requireOpen;
3334
3341
  if (yield* access.requiresRefresh) {
@@ -4876,6 +4883,20 @@ function chatError(message) {
4876
4883
  return new SpaceClientError({ code: "chat.payload_invalid", recoverable: false, message });
4877
4884
  }
4878
4885
 
4886
+ // src/space-client/same-identity.ts
4887
+ function otherDevices(participants, participantId, ownIdentityId) {
4888
+ const identityId = ownIdentityId ?? participants.find((item) => item.participantId === participantId)?.identityId;
4889
+ return identityId ? participants.filter((item) => item.participantId !== participantId && item.identityId === identityId) : [];
4890
+ }
4891
+ function otherDeviceHasMicrophone(snapshot, participants) {
4892
+ if (snapshot.control?.status === "ended") return false;
4893
+ const admitted = snapshot.control ? new Set(snapshot.control.participants.map((item) => item.participantId)) : null;
4894
+ const devices = new Set(
4895
+ otherDevices(participants, snapshot.participantId, snapshot.participantIdentityId).filter((item) => !admitted || admitted.has(item.participantId)).map((item) => item.participantId)
4896
+ );
4897
+ return snapshot.media?.items.some((item) => devices.has(item.participantId) && item.source === "microphone" && item.enabled) ?? false;
4898
+ }
4899
+
4879
4900
  // src/space-client/media-controller.ts
4880
4901
  import { Clock as Clock3, Context as Context6, Deferred as Deferred3, Effect as Effect5, Exit as Exit2, Layer as Layer6, Semaphore } from "effect";
4881
4902
 
@@ -4949,7 +4970,7 @@ var MediaProjectionMonitor = class {
4949
4970
  var MEDIA_SOURCES = ["microphone", "camera", "screen"];
4950
4971
  var MEDIA_READY_TIMEOUT_MS = 1e4;
4951
4972
  function sourceWorker(enabled) {
4952
- return { desired: { enabled, revision: 0, controlRevision: -1, previousEnabled: enabled }, result: { revision: 0, status: "confirmed" }, initialRevision: 0, localEnabled: enabled, pause: null, serverSequence: -1, forcedRevision: null, pausedPublicationId: null };
4973
+ return { desired: { enabled, revision: 0, controlRevision: -1, previousEnabled: enabled }, result: { revision: 0, status: "confirmed" }, initialRevision: 0, localEnabled: enabled, pause: null, serverSequence: -1, forcedRevision: null, pausedPublicationId: null, capturePause: "none" };
4953
4974
  }
4954
4975
  var MediaControllerService = class extends Context6.Service()("@chalk/client/MediaController") {
4955
4976
  };
@@ -4992,6 +5013,7 @@ var MediaControllerRuntime = class {
4992
5013
  #tracks = /* @__PURE__ */ new Map();
4993
5014
  #sources = { microphone: sourceWorker(true), camera: sourceWorker(true), screen: sourceWorker(false) };
4994
5015
  #syncLive = false;
5016
+ #disposed = false;
4995
5017
  #recovering = null;
4996
5018
  #pendingSources = /* @__PURE__ */ new Map();
4997
5019
  #pendingCaptures = /* @__PURE__ */ new Set();
@@ -5010,6 +5032,7 @@ var MediaControllerRuntime = class {
5010
5032
  #unsubscribePorts = null;
5011
5033
  #microphoneWatch = null;
5012
5034
  #projection;
5035
+ #microphoneMutedByOtherDevice = false;
5013
5036
  constructor(connection, store, selection, gates, now, fork) {
5014
5037
  this.#connection = connection;
5015
5038
  this.#store = store;
@@ -5024,7 +5047,7 @@ var MediaControllerRuntime = class {
5024
5047
  changed: () => this.#publish(),
5025
5048
  report: (event) => connection.recordTransportDiagnostic(event),
5026
5049
  pause: (source, revision) => {
5027
- if (this.#ports) this.#pausePublication(this.#ports, source, revision);
5050
+ if (this.#ports) this.#pausePublication(this.#ports, source, revision, "projection_mismatch");
5028
5051
  }
5029
5052
  });
5030
5053
  this.#unsubscribeConnection = connection.subscribe(() => this.#handleConnectionChange());
@@ -5035,7 +5058,10 @@ var MediaControllerRuntime = class {
5035
5058
  if (intent.microphone !== void 0) this.#recordIntent("microphone", intent.microphone);
5036
5059
  if (intent.camera !== void 0) this.#recordIntent("camera", intent.camera);
5037
5060
  }
5038
- setMicrophoneEnabled = (enabled) => this.#set("microphone", enabled);
5061
+ setMicrophoneEnabled = (enabled) => Effect5.sync(() => {
5062
+ this.#microphoneMutedByOtherDevice = false;
5063
+ this.#publish();
5064
+ }).pipe(Effect5.andThen(this.#set("microphone", enabled)));
5039
5065
  setCameraEnabled = (enabled) => this.#set("camera", enabled);
5040
5066
  captureCameraPreview = () => this.#cameraSource("preview").pipe(Effect5.mapError(normalizeMediaError));
5041
5067
  setScreenShareEnabled = (enabled, options) => enabled ? this.#mediaAction(true, this.#startScreen(options)) : this.#stopScreen();
@@ -5130,8 +5156,9 @@ var MediaControllerRuntime = class {
5130
5156
  this.#fork(this.#refreshDevices().pipe(Effect5.ignore));
5131
5157
  }
5132
5158
  dispose() {
5159
+ this.#disposed = true;
5133
5160
  this.#projection.reset();
5134
- for (const check of this.#readinessChecks) check(normalizeMediaError(cancelledCaptureError()));
5161
+ for (const check of this.#readinessChecks) check(normalizeMediaError(this.#cancelledCaptureError()));
5135
5162
  this.#cancelCaptures();
5136
5163
  for (const unsubscribe of [this.#unsubscribeMedia, this.#unsubscribeRequests, this.#unsubscribePause, this.#unsubscribeSync]) unsubscribe?.();
5137
5164
  this.#unsubscribeConnection?.();
@@ -5161,7 +5188,7 @@ var MediaControllerRuntime = class {
5161
5188
  const check = (failure2) => {
5162
5189
  if (failure2) return resume(Effect5.fail(failure2));
5163
5190
  if (this.#sources[source].desired.revision !== revision) return resume(Effect5.void);
5164
- if (ports && !this.#canUsePorts(ports)) return resume(Effect5.fail(normalizeMediaError(cancelledCaptureError())));
5191
+ if (ports && !this.#canUsePorts(ports)) return resume(Effect5.fail(normalizeMediaError(this.#cancelledCaptureError())));
5165
5192
  const state = this.#connection.getSnapshot().state;
5166
5193
  const phases = [state, this.#ports?.sync.getSnapshot().connection.phase, this.#store.getSnapshot().connection.status];
5167
5194
  if (phases.every((phase) => phase === "live")) return resume(Effect5.void);
@@ -5210,6 +5237,7 @@ var MediaControllerRuntime = class {
5210
5237
  const controlRevision = Math.max(state.serverSequence, this.#ports?.sync.getSnapshot().control?.revision ?? -1);
5211
5238
  const desired = { enabled, revision: state.desired.revision + 1, controlRevision, options, previousEnabled: state.localEnabled };
5212
5239
  state.desired = desired;
5240
+ state.capturePause = "none";
5213
5241
  if (!enabled) {
5214
5242
  state.localEnabled = false;
5215
5243
  this.#pauseTracks(source);
@@ -5219,8 +5247,8 @@ var MediaControllerRuntime = class {
5219
5247
  }
5220
5248
  const ports = this.#ports;
5221
5249
  if (ports) {
5222
- ports.media.setLocalSourceIntent?.(source, false);
5223
- this.#retainPause(state, this.#beginLocalPause(ports, source));
5250
+ ports.media.setLocalSourceIntent?.(source, false, "user_pause");
5251
+ this.#retainPause(state, this.#beginLocalPause(ports, source, "user_pause"));
5224
5252
  }
5225
5253
  }
5226
5254
  for (const check of this.#readinessChecks) check();
@@ -5316,11 +5344,11 @@ var MediaControllerRuntime = class {
5316
5344
  if (state.desired.revision !== intent.revision) return;
5317
5345
  state.result = { revision: intent.revision, status: "failed", cause, cleanup: !intent.enabled || !previous };
5318
5346
  if (state.result.cleanup) {
5319
- if (intent.enabled && source !== "screen") this.#pausePublication(ports, source, intent.revision);
5347
+ if (intent.enabled && source !== "screen") this.#pausePublication(ports, source, intent.revision, "enable_failure");
5320
5348
  else {
5321
5349
  state.localEnabled = false;
5322
5350
  this.#pauseTracks(source);
5323
- ports.media.setLocalSourceIntent?.(source, false);
5351
+ ports.media.setLocalSourceIntent?.(source, false, "enable_failure");
5324
5352
  }
5325
5353
  }
5326
5354
  });
@@ -5434,7 +5462,7 @@ var MediaControllerRuntime = class {
5434
5462
  }
5435
5463
  #assertCurrentIntent(source, revision, enabling = true) {
5436
5464
  const state = this.#sources[source];
5437
- if (state.desired.revision !== revision || enabling && state.forcedRevision === revision) throw cancelledCaptureError();
5465
+ if (state.desired.revision !== revision || enabling && state.forcedRevision === revision) throw this.#cancelledCaptureError();
5438
5466
  }
5439
5467
  #hasConsent(source) {
5440
5468
  const state = this.#sources[source];
@@ -5458,19 +5486,36 @@ var MediaControllerRuntime = class {
5458
5486
  }
5459
5487
  }
5460
5488
  startInitial(media, stream) {
5489
+ const waitForSync = Effect5.callback((resume) => {
5490
+ const sync = this.#ports?.sync;
5491
+ if (!sync) return resume(Effect5.void);
5492
+ const check = () => {
5493
+ if (sync.getSnapshot().connection.phase === "live" || media.getSnapshot().connection.phase === "stopped") resume(Effect5.void);
5494
+ };
5495
+ const unsubscribe = sync.subscribe(check);
5496
+ const unsubscribeMedia = media.subscribe(check);
5497
+ check();
5498
+ return Effect5.sync(() => {
5499
+ unsubscribe();
5500
+ unsubscribeMedia();
5501
+ });
5502
+ });
5461
5503
  const publish = foreign2(async () => {
5462
5504
  const revisions = { microphone: this.#sources.microphone.initialRevision, camera: this.#sources.camera.initialRevision };
5463
- const tracks = stream.getTracks().filter((track) => {
5464
- const source = track.kind === "audio" ? "microphone" : "camera";
5465
- const enabled = this.#hasConsent(source);
5466
- track.enabled = enabled;
5467
- if (!enabled) {
5468
- this.#deleteTrack(source);
5469
- if (source === "microphone") track.stop();
5470
- }
5471
- return enabled;
5472
- });
5473
- await media.start(streamFromTracks(tracks));
5505
+ const tracks = this.#consentedTracks(stream.getTracks());
5506
+ await media.start(
5507
+ streamFromTracks(tracks),
5508
+ () => Effect5.runPromise(
5509
+ waitForSync.pipe(
5510
+ Effect5.map(() => {
5511
+ const snapshot = this.#ports?.sync.getSnapshot();
5512
+ this.#microphoneMutedByOtherDevice = Boolean(snapshot && this.#hasConsent("microphone") && otherDeviceHasMicrophone(snapshot, this.#store.getSnapshot().participants.roster));
5513
+ if (this.#microphoneMutedByOtherDevice) this.#recordIntent("microphone", false);
5514
+ return streamFromTracks(this.#consentedTracks(tracks));
5515
+ })
5516
+ )
5517
+ )
5518
+ );
5474
5519
  for (const source of ["microphone", "camera"]) {
5475
5520
  const state = this.#sources[source];
5476
5521
  if (state.desired.revision === revisions[source]) state.result = { revision: revisions[source], status: "confirmed" };
@@ -5478,6 +5523,18 @@ var MediaControllerRuntime = class {
5478
5523
  });
5479
5524
  return this.#serialize("microphone", this.#serialize("camera", publish));
5480
5525
  }
5526
+ #consentedTracks(tracks) {
5527
+ return tracks.filter((track) => {
5528
+ const source = track.kind === "audio" ? "microphone" : "camera";
5529
+ const enabled = this.#hasConsent(source);
5530
+ track.enabled = enabled;
5531
+ if (!enabled) {
5532
+ this.#deleteTrack(source);
5533
+ if (source === "microphone") track.stop();
5534
+ }
5535
+ return enabled;
5536
+ });
5537
+ }
5481
5538
  #send(ports, source, intent, enabled) {
5482
5539
  const command = this.#mediaAction(
5483
5540
  enabled,
@@ -5519,9 +5576,9 @@ var MediaControllerRuntime = class {
5519
5576
  )
5520
5577
  );
5521
5578
  }
5522
- #beginLocalPause(ports, source) {
5579
+ #beginLocalPause(ports, source, cause) {
5523
5580
  this.#sources[source].pausedPublicationId = ports.media.getSnapshot().localTracks.find((item) => item.source === source)?.publicationId ?? null;
5524
- const pause = ports.media.closeForcedLocalPublication ? Promise.resolve().then(() => ports.media.closeForcedLocalPublication(source)) : Effect5.runPromise(this.#localTarget(ports, source, false));
5581
+ const pause = ports.media.closeForcedLocalPublication ? Promise.resolve().then(() => ports.media.closeForcedLocalPublication(source, cause)) : Effect5.runPromise(this.#localTarget(ports, source, false));
5525
5582
  void pause.catch(() => void 0);
5526
5583
  return pause;
5527
5584
  }
@@ -5594,15 +5651,15 @@ var MediaControllerRuntime = class {
5594
5651
  return;
5595
5652
  }
5596
5653
  const state = this.#camera;
5597
- if (!("owner" in state)) throw cancelledCaptureError();
5598
- if (state.status === "live" && state.track !== track) throw cancelledCaptureError();
5654
+ if (!("owner" in state)) throw this.#cancelledCaptureError();
5655
+ if (state.status === "live" && state.track !== track) throw this.#cancelledCaptureError();
5599
5656
  const onEnded = () => this.#handleCameraEnded(track);
5600
5657
  this.#transitionCamera({ status: "live", owner: state.owner, track, publication: this.#ports?.media ?? "initial", onEnded });
5601
5658
  }
5602
5659
  #handleCameraEnded(track) {
5603
5660
  const state = this.#camera;
5604
5661
  if (state.status !== "live" || state.track !== track) return;
5605
- if (this.#ports && state.publication === this.#ports.media) this.#pausePublication(this.#ports, "camera", this.#sources.camera.desired.revision);
5662
+ if (this.#ports && state.publication === this.#ports.media) this.#pausePublication(this.#ports, "camera", this.#sources.camera.desired.revision, "capture_cleanup");
5606
5663
  this.#transitionCamera({ status: "ended" });
5607
5664
  }
5608
5665
  #watchMicrophone(track) {
@@ -5615,7 +5672,7 @@ var MediaControllerRuntime = class {
5615
5672
  if (track.muted || track.readyState === "ended") {
5616
5673
  track.enabled = false;
5617
5674
  const state = this.#sources.microphone;
5618
- if (this.#ports) this.#pausePublication(this.#ports, "microphone", state.desired.revision);
5675
+ if (this.#ports) this.#pausePublication(this.#ports, "microphone", state.desired.revision, "capture_cleanup");
5619
5676
  else {
5620
5677
  state.forcedRevision = state.desired.revision;
5621
5678
  state.localEnabled = false;
@@ -5694,7 +5751,7 @@ var MediaControllerRuntime = class {
5694
5751
  references: 0,
5695
5752
  cancel: () => {
5696
5753
  abort.abort();
5697
- Deferred3.doneUnsafe(owner.result, Effect5.fail(cancelledCaptureError()));
5754
+ Deferred3.doneUnsafe(owner.result, Effect5.fail(this.#cancelledCaptureError()));
5698
5755
  }
5699
5756
  };
5700
5757
  this.#transitionCamera({ status: "capturing", owner });
@@ -5706,13 +5763,13 @@ var MediaControllerRuntime = class {
5706
5763
  const state = this.#camera;
5707
5764
  if (abort.signal.aborted || !("owner" in state) || state.owner !== owner) {
5708
5765
  track.stop();
5709
- throw cancelledCaptureError();
5766
+ throw this.#cancelledCaptureError();
5710
5767
  }
5711
5768
  const onEnded = () => this.#handleCameraEnded(track);
5712
5769
  this.#transitionCamera({ status: "live", owner, track, publication: state.status === "live" ? state.publication : null, onEnded });
5713
5770
  if (track.readyState !== "live") {
5714
5771
  this.#transitionCamera({ status: "ended" });
5715
- throw cancelledCaptureError();
5772
+ throw this.#cancelledCaptureError();
5716
5773
  }
5717
5774
  },
5718
5775
  catch: (cause) => cause
@@ -5735,7 +5792,7 @@ var MediaControllerRuntime = class {
5735
5792
  if (owner.references === 0 && "owner" in current && current.owner === owner && (current.status !== "live" || current.publication === null)) this.#transitionCamera({ status: "ended" });
5736
5793
  };
5737
5794
  return Deferred3.await(owner.result).pipe(
5738
- Effect5.flatMap((track) => track.readyState === "live" ? Effect5.succeed({ track, release }) : Effect5.fail(cancelledCaptureError())),
5795
+ Effect5.flatMap((track) => track.readyState === "live" ? Effect5.succeed({ track, release }) : Effect5.fail(this.#cancelledCaptureError())),
5739
5796
  Effect5.onExit(
5740
5797
  (exit) => Effect5.sync(() => {
5741
5798
  if (Exit2.isFailure(exit)) release();
@@ -5768,25 +5825,25 @@ var MediaControllerRuntime = class {
5768
5825
  const cancel = () => {
5769
5826
  cancelled = true;
5770
5827
  stopStream(captured);
5771
- Deferred3.doneUnsafe(cancellation, Effect5.fail(cancelledCaptureError()));
5828
+ Deferred3.doneUnsafe(cancellation, Effect5.fail(this.#cancelledCaptureError()));
5772
5829
  };
5773
5830
  this.#pendingCaptures.add(cancel);
5774
5831
  signal?.addEventListener("abort", cancel, { once: true });
5775
5832
  if (signal?.aborted) cancel();
5776
5833
  return foreign2(async () => {
5777
- if (cancelled) throw cancelledCaptureError();
5834
+ if (cancelled) throw this.#cancelledCaptureError();
5778
5835
  const stream = await operation();
5779
5836
  captured = stream;
5780
5837
  if (cancelled || this.#ports?.media.getSnapshot().connection.phase === "stopped") {
5781
5838
  stopStream(stream);
5782
- throw cancelledCaptureError();
5839
+ throw this.#cancelledCaptureError();
5783
5840
  }
5784
5841
  return stream;
5785
5842
  }).pipe(
5786
5843
  Effect5.mapError(onCaptureError),
5787
5844
  Effect5.flatMap(use),
5788
5845
  Effect5.raceFirst(Deferred3.await(cancellation)),
5789
- Effect5.tap(() => cancelled ? Effect5.fail(cancelledCaptureError()) : Effect5.void),
5846
+ Effect5.tap(() => cancelled ? Effect5.fail(this.#cancelledCaptureError()) : Effect5.void),
5790
5847
  Effect5.onExit(
5791
5848
  (exit) => Effect5.sync(() => {
5792
5849
  this.#pendingCaptures.delete(cancel);
@@ -5860,23 +5917,48 @@ var MediaControllerRuntime = class {
5860
5917
  const failure2 = isPermissionDenied(input.cause) || captureWasUnavailable ? captureError(input.cause, input.action, input.permissionMessage) : input.cause;
5861
5918
  return rollback.pipe(Effect5.andThen(Effect5.sync(() => this.#publish())), Effect5.andThen(Effect5.fail(failure2)));
5862
5919
  }
5920
+ #cancelledCaptureError() {
5921
+ return new ConnectionError({ code: "invalid_state", action: null, recoverable: !this.#disposed, message: this.#disposed ? "This media client has been disposed. Rejoin the call." : "Media capture was cancelled before its result could be used" });
5922
+ }
5863
5923
  #canUsePorts(ports) {
5864
5924
  return this.#ports?.sync === ports.sync && this.#ports.media === ports.media && ports.media.getSnapshot().connection.phase !== "stopped";
5865
5925
  }
5866
5926
  #assertActivePorts(ports, action) {
5867
5927
  if (this.#connection.getSnapshot().state === "live" && this.#canUsePorts(ports)) return;
5868
- throw new ConnectionError({ code: "invalid_state", action, recoverable: false, message: `${action} belongs to an inactive connection` });
5928
+ throw new ConnectionError({ code: "invalid_state", action, recoverable: !this.#disposed, message: this.#disposed ? "This media client has been disposed. Rejoin the call." : `${action} belongs to an inactive connection` });
5869
5929
  }
5870
- #pausePublication(ports, source, revision) {
5930
+ #pausePublication(ports, source, revision, cause) {
5871
5931
  const state = this.#sources[source];
5872
5932
  if (state.desired.revision !== revision || state.forcedRevision === revision || ports.sync.getSnapshot().control?.status === "ended") return;
5873
5933
  state.forcedRevision = revision;
5874
5934
  state.localEnabled = false;
5875
5935
  this.#pauseTracks(source);
5876
- ports.media.setLocalSourceIntent?.(source, false);
5877
- this.#retainPause(state, this.#beginLocalPause(ports, source));
5936
+ if (cause === "capture_cleanup" && source !== "screen" && ports.media.pauseLocalCapture) {
5937
+ state.capturePause = "pending";
5938
+ this.#notifyCapturePauses(ports);
5939
+ this.#retainPause(state, ports.media.pauseLocalCapture(source));
5940
+ } else {
5941
+ ports.media.setLocalSourceIntent?.(source, false, cause);
5942
+ this.#retainPause(state, this.#beginLocalPause(ports, source, cause));
5943
+ }
5878
5944
  this.#publish();
5879
5945
  }
5946
+ #notifyCapturePauses(ports) {
5947
+ for (const source of ["microphone", "camera"]) {
5948
+ const state = this.#sources[source];
5949
+ if (state.capturePause === "pending") this.#retainPause(state, this.#announceCapturePause(ports, source));
5950
+ }
5951
+ }
5952
+ async #announceCapturePause(ports, source) {
5953
+ const snapshot = ports.sync.getSnapshot();
5954
+ if (snapshot.connection.phase !== "live") return;
5955
+ const owned = ports.media.getSnapshot().localTracks.find((item) => item.source === source);
5956
+ const projected = snapshot.media?.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
5957
+ if (!owned?.publicationId || !projected?.enabled || projected.publicationId !== owned.publicationId) return;
5958
+ this.#sources[source].capturePause = "announced";
5959
+ const send = source === "microphone" ? ports.sync.setMicrophoneEnabled : ports.sync.setCameraEnabled;
5960
+ await send.call(ports.sync, false);
5961
+ }
5880
5962
  #bind(ports) {
5881
5963
  if (ports?.media.getSnapshot().connection.phase === "stopped") this.#cancelCaptures();
5882
5964
  if (ports && this.#ports?.sync === ports.sync && this.#ports.media === ports.media) return;
@@ -5914,10 +5996,11 @@ var MediaControllerRuntime = class {
5914
5996
  const state = this.#sources[pause.source];
5915
5997
  if (pause.revision <= state.serverSequence) return;
5916
5998
  state.serverSequence = pause.revision;
5917
- if (pause.revision > state.desired.controlRevision) this.#pausePublication(ports, pause.source, state.desired.revision);
5999
+ if (pause.revision > state.desired.controlRevision) this.#pausePublication(ports, pause.source, state.desired.revision, "moderation_pause");
5918
6000
  }) ?? null;
5919
6001
  }
5920
6002
  #rememberRecoveryIntents() {
6003
+ for (const state of Object.values(this.#sources)) if (state.capturePause === "announced") state.capturePause = "pending";
5921
6004
  if (!this.#ports) return;
5922
6005
  this.#recovering ??= new Map(MEDIA_SOURCES.filter((source) => this.#sources[source].desired.enabled).map((source) => [source, this.#sources[source].desired.revision]));
5923
6006
  }
@@ -5926,7 +6009,7 @@ var MediaControllerRuntime = class {
5926
6009
  this.#recovering = null;
5927
6010
  for (const [source, revision] of recovering ?? []) {
5928
6011
  const current = snapshot.media?.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
5929
- if (!current?.enabled) this.#pausePublication(ports, source, revision);
6012
+ if (!current?.enabled) this.#pausePublication(ports, source, revision, "sync_recovery_loss");
5930
6013
  }
5931
6014
  }
5932
6015
  #subscribeSync(ports) {
@@ -5934,6 +6017,7 @@ var MediaControllerRuntime = class {
5934
6017
  this.#removeRequestsFromMissingParticipants(snapshot);
5935
6018
  this.#applyRemoteMediaState(ports, snapshot);
5936
6019
  this.#reconcileIntents();
6020
+ this.#notifyCapturePauses(ports);
5937
6021
  this.#publish();
5938
6022
  });
5939
6023
  }
@@ -6029,8 +6113,8 @@ var MediaControllerRuntime = class {
6029
6113
  const state = this.#sources[source];
6030
6114
  if (!this.#hasConsent(source) && publication?.enabled && state.pausedPublicationId !== publication.publicationId) {
6031
6115
  this.#pauseTracks(source);
6032
- ports.media.setLocalSourceIntent?.(source, false);
6033
- this.#retainPause(state, this.#beginLocalPause(ports, source));
6116
+ ports.media.setLocalSourceIntent?.(source, false, "consent_revoked");
6117
+ this.#retainPause(state, this.#beginLocalPause(ports, source, "consent_revoked"));
6034
6118
  }
6035
6119
  }
6036
6120
  #observeProjection(ports, source) {
@@ -6070,6 +6154,7 @@ var MediaControllerRuntime = class {
6070
6154
  const remote = Object.freeze((snapshot?.remoteTracks ?? []).map((track) => Object.freeze({ participantId: track.participantId, source: track.source, publicationId: track.publicationId, track: track.track })));
6071
6155
  const media = Object.freeze({
6072
6156
  ...current.media,
6157
+ microphoneMutedByOtherDevice: this.#microphoneMutedByOtherDevice,
6073
6158
  local,
6074
6159
  remote,
6075
6160
  screenShare: {
@@ -6094,9 +6179,6 @@ function confirmedLocalMediaState(track, desired, pending, confirmed, live, conv
6094
6179
  if (!live && track && liveTrack(track)) return "requesting";
6095
6180
  return confirmed ? "enabled" : pending ? "requesting" : "disabled";
6096
6181
  }
6097
- function cancelledCaptureError() {
6098
- return new ConnectionError({ code: "invalid_state", action: null, recoverable: false, message: "Media capture was cancelled before its result could be used" });
6099
- }
6100
6182
  function foreign2(operation) {
6101
6183
  return Effect5.tryPromise({ try: operation, catch: (cause) => cause });
6102
6184
  }
@@ -6463,6 +6545,12 @@ var ParticipantsControllerRuntime = class {
6463
6545
  #unsubscribePorts = null;
6464
6546
  #unsubscribe = null;
6465
6547
  #unsubscribeMedia = null;
6548
+ #deviceScope = null;
6549
+ // Retain verified seats while the local Sync replica is recovering, and for older servers
6550
+ // whose negotiated hints disappear with sockets. New servers send every admitted seat.
6551
+ #devices = /* @__PURE__ */ new Map();
6552
+ #presenceCursor = null;
6553
+ #invalidatedDevices = /* @__PURE__ */ new Set();
6466
6554
  constructor(connection, store, offline) {
6467
6555
  this.#offline = offline;
6468
6556
  this.#connection = connection;
@@ -6516,6 +6604,8 @@ var ParticipantsControllerRuntime = class {
6516
6604
  return ({ sync }) => foreign3(() => sync.setDisplayName(displayName));
6517
6605
  });
6518
6606
  dispose() {
6607
+ this.#devices.clear();
6608
+ this.#invalidatedDevices.clear();
6519
6609
  this.#unsubscribePorts?.();
6520
6610
  this.#unsubscribe?.();
6521
6611
  this.#unsubscribeMedia?.();
@@ -6535,6 +6625,10 @@ var ParticipantsControllerRuntime = class {
6535
6625
  this.#unsubscribe = null;
6536
6626
  this.#unsubscribeMedia = null;
6537
6627
  if (!ports) {
6628
+ if (["idle", "leaving", "left", "failed"].includes(this.#connection.getSnapshot().state)) {
6629
+ this.#devices.clear();
6630
+ this.#invalidatedDevices.clear();
6631
+ }
6538
6632
  this.#publish(null, {});
6539
6633
  return;
6540
6634
  }
@@ -6543,11 +6637,57 @@ var ParticipantsControllerRuntime = class {
6543
6637
  this.#unsubscribeMedia = ports.media.subscribe(publish);
6544
6638
  publish();
6545
6639
  }
6640
+ #syncDeviceScope() {
6641
+ const subject = this.#connection.getSnapshot().subject;
6642
+ const scope = subject ? JSON.stringify([subject.tenantId, subject.spaceId, subject.episodeId, subject.participantId, subject.participantGeneration]) : null;
6643
+ if (scope !== this.#deviceScope) {
6644
+ this.#devices.clear();
6645
+ this.#invalidatedDevices.clear();
6646
+ }
6647
+ this.#deviceScope = scope;
6648
+ }
6649
+ #rememberDevices(snapshot) {
6650
+ this.#syncDeviceScope();
6651
+ const control = snapshot?.control;
6652
+ if (!control) return;
6653
+ this.#pruneDevices(control, snapshot.presence);
6654
+ const retained = /* @__PURE__ */ new Map();
6655
+ if (control.status !== "ended") {
6656
+ for (const participant of control.participants) {
6657
+ const device = this.#deviceFor(participant, snapshot.presence?.items ?? []);
6658
+ if (device) retained.set(participant.participantId, device);
6659
+ }
6660
+ }
6661
+ this.#devices = retained;
6662
+ }
6663
+ #pruneDevices(control, presence) {
6664
+ const cursor = presence ? JSON.stringify([presence.projectionId, presence.sequence]) : null;
6665
+ if (cursor !== this.#presenceCursor) this.#invalidatedDevices.clear();
6666
+ this.#presenceCursor = cursor;
6667
+ for (const [id, device] of this.#devices) {
6668
+ if (!control.participants.some((participant) => participant.participantId === id && participant.admissionRevision === device.admissionRevision)) this.#invalidatedDevices.add(id);
6669
+ }
6670
+ }
6671
+ #deviceFor(participant, presence) {
6672
+ const previous = this.#devices.get(participant.participantId);
6673
+ const known = previous && previous.admissionRevision === participant.admissionRevision ? previous : {};
6674
+ const hint = this.#hintFor(participant, presence) ?? {};
6675
+ const identityId = hint.identityId ?? known.identityId;
6676
+ return identityId ? { admissionRevision: participant.admissionRevision, identityId, deviceType: hint.deviceType ?? known.deviceType } : void 0;
6677
+ }
6678
+ #hintFor(participant, presence) {
6679
+ return presence.find((item) => {
6680
+ if (item.participantId !== participant.participantId) return false;
6681
+ if (item.admissionRevision !== void 0) return item.admissionRevision === participant.admissionRevision;
6682
+ return !this.#invalidatedDevices.has(participant.participantId);
6683
+ });
6684
+ }
6546
6685
  #publish(snapshot, collaborationCapabilities, media) {
6686
+ this.#rememberDevices(snapshot);
6547
6687
  if (snapshot && !controlFor(snapshot)) return;
6548
6688
  const audioParticipants = new Set(media?.remoteTracks.filter((track) => track.source === "screen_audio" && track.track.readyState === "live").map((track) => track.participantId));
6549
6689
  if (snapshot?.participantId && media?.localTracks.some((track) => track.source === "screen_audio" && track.enabled && track.track.readyState === "live")) audioParticipants.add(snapshot.participantId);
6550
- const projected = participantsFor(snapshot, collaborationCapabilities);
6690
+ const projected = participantsFor(snapshot, collaborationCapabilities, this.#devices);
6551
6691
  const participants = Object.freeze({
6552
6692
  ...projected,
6553
6693
  roster: Object.freeze(projected.roster.map((participant) => Object.freeze({ ...participant, media: Object.freeze({ ...participant.media, screenShareHasAudio: participant.media.screenShare === "active" && audioParticipants.has(participant.participantId) }) })))
@@ -6573,10 +6713,10 @@ function invalid(message) {
6573
6713
  function directed(result) {
6574
6714
  return Object.freeze({ status: result.result, requestId: result.request_id });
6575
6715
  }
6576
- function participantsFor(snapshot, collaborationCapabilities) {
6716
+ function participantsFor(snapshot, collaborationCapabilities, devices) {
6577
6717
  const control = controlFor(snapshot);
6578
- if (!control) return EMPTY_PARTICIPANTS;
6579
- return Object.freeze({ roster: rosterFor(control.participants, collaborationCapabilities, mediaFor(snapshot), presenceFor(snapshot)), admissionQueue: admissionQueueFor(control.admissionRequests) });
6718
+ if (!control || control.status === "ended") return EMPTY_PARTICIPANTS;
6719
+ return Object.freeze({ roster: rosterFor(control.participants, collaborationCapabilities, mediaFor(snapshot), presenceFor(snapshot), devices), admissionQueue: admissionQueueFor(control.admissionRequests) });
6580
6720
  }
6581
6721
  function controlFor(snapshot) {
6582
6722
  if (!snapshot) return null;
@@ -6590,11 +6730,13 @@ function mediaFor(snapshot) {
6590
6730
  function presenceFor(snapshot) {
6591
6731
  return snapshot?.presence ?? null;
6592
6732
  }
6593
- function rosterFor(participants, collaborationCapabilities, media, presence) {
6733
+ function rosterFor(participants, collaborationCapabilities, media, presence, devices) {
6594
6734
  return Object.freeze(
6595
6735
  participants.map(
6596
6736
  (participant) => Object.freeze({
6597
6737
  participantId: participant.participantId,
6738
+ identityId: devices.get(participant.participantId)?.identityId,
6739
+ deviceType: devices.get(participant.participantId)?.deviceType,
6598
6740
  displayName: participant.displayName,
6599
6741
  role: participant.role,
6600
6742
  eligibleRoles: Object.freeze([...participant.eligibleRoles]),
@@ -6616,13 +6758,20 @@ function admissionQueueFor(requests) {
6616
6758
  function selfFor(snapshot, roster) {
6617
6759
  const participantId = snapshot?.participantId ?? null;
6618
6760
  const participant = roster.find((candidate) => candidate.participantId === participantId);
6619
- if (!participant) return emptySelf(participantId);
6761
+ const identityId = selfIdentityFor(snapshot, participant);
6762
+ if (!participant) return emptySelf(participantId, identityId);
6620
6763
  const capabilities = participant.capabilities;
6621
6764
  const allowed = new Set(capabilities);
6622
- return Object.freeze({ participantId, displayName: participant.displayName, role: participant.role, capabilities, handRaised: participant.handRaised, can: (capability) => allowed.has(capability) });
6765
+ return Object.freeze({ participantId, identityId, displayName: participant.displayName, role: participant.role, capabilities, handRaised: participant.handRaised, can: (capability) => allowed.has(capability) });
6766
+ }
6767
+ function selfIdentityFor(snapshot, participant) {
6768
+ if (!snapshot) return void 0;
6769
+ if (snapshot.connection.phase === "terminal") return void 0;
6770
+ if (snapshot.control?.status === "ended") return void 0;
6771
+ return snapshot.participantIdentityId ?? participant?.identityId;
6623
6772
  }
6624
- function emptySelf(participantId) {
6625
- return Object.freeze({ participantId, displayName: null, role: null, capabilities: Object.freeze([]), handRaised: false, can: () => false });
6773
+ function emptySelf(participantId, identityId) {
6774
+ return Object.freeze({ participantId, identityId, displayName: null, role: null, capabilities: Object.freeze([]), handRaised: false, can: () => false });
6626
6775
  }
6627
6776
  function capabilitiesFor(control, collaboration) {
6628
6777
  const capabilities = /* @__PURE__ */ new Set();
@@ -6656,10 +6805,10 @@ function sameParticipantMedia(left, right) {
6656
6805
  return left.media.microphone === right.media.microphone && left.media.camera === right.media.camera && left.media.screenShare === right.media.screenShare && left.media.screenShareHasAudio === right.media.screenShareHasAudio;
6657
6806
  }
6658
6807
  function sameParticipantPresence(left, right) {
6659
- return left.presence.state === right.presence.state && left.presence.speaking === right.presence.speaking && left.presence.activeSpeaker === right.presence.activeSpeaker;
6808
+ return left.identityId === right.identityId && left.deviceType === right.deviceType && left.presence.state === right.presence.state && left.presence.speaking === right.presence.speaking && left.presence.activeSpeaker === right.presence.activeSpeaker;
6660
6809
  }
6661
6810
  function sameSelf(left, right) {
6662
- return left.participantId === right.participantId && left.displayName === right.displayName && left.role === right.role && left.handRaised === right.handRaised && sameValues(left.capabilities, right.capabilities);
6811
+ return left.participantId === right.participantId && left.identityId === right.identityId && left.displayName === right.displayName && left.role === right.role && left.handRaised === right.handRaised && sameValues(left.capabilities, right.capabilities);
6663
6812
  }
6664
6813
  function sameArray(left, right, equal) {
6665
6814
  return left.length === right.length && left.every((value, index) => equal(value, right[index]));
@@ -7193,7 +7342,7 @@ var SpaceClientCore = class {
7193
7342
  }
7194
7343
  }
7195
7344
  #requireActive() {
7196
- if (this.#disposed) throw new SpaceClientError({ code: "connection.invalid_state", recoverable: false, message: "The SpaceClient has been disposed" });
7345
+ if (this.#disposed) throw new SpaceClientError({ code: "connection.invalid_state", recoverable: false, message: "The SpaceClient has been disposed. Rejoin the call." });
7197
7346
  }
7198
7347
  };
7199
7348
  function liveClock(dependencies) {