@q9labsai/chalk-client 5.2.4 → 5.2.6

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/access/grant.d.ts +2 -1
  3. package/dist/access/manager.d.ts +1 -0
  4. package/dist/{chunk-AZONR3Q3.js → chunk-CJVLTSIO.js} +120 -8
  5. package/dist/{chunk-LDPC2JZB.js → chunk-I7Y4HR2P.js} +711 -194
  6. package/dist/{chunk-SYUGRHZ5.js → chunk-WFRTUBN6.js} +1 -1
  7. package/dist/{chunk-3ASKGMD3.js → chunk-YIDKETOX.js} +410 -232
  8. package/dist/client.d.ts +74 -2
  9. package/dist/connection/dependencies.d.ts +4 -1
  10. package/dist/connection/diagnostics.d.ts +4 -0
  11. package/dist/connection/episode-status.d.ts +7 -0
  12. package/dist/connection/index.d.ts +2 -0
  13. package/dist/connection/lifecycle.d.ts +2 -0
  14. package/dist/connection/production.d.ts +1 -1
  15. package/dist/effect.js +19 -3
  16. package/dist/generated/http-api.d.ts +73 -2
  17. package/dist/generated/schemas.d.ts +126 -4
  18. package/dist/index.js +4 -4
  19. package/dist/media/client.d.ts +4 -3
  20. package/dist/media/failure-diagnostics.d.ts +10 -0
  21. package/dist/media/index.js +1 -1
  22. package/dist/media/plane.d.ts +7 -2
  23. package/dist/media/types.d.ts +4 -2
  24. package/dist/public-invites/index.js +2 -2
  25. package/dist/server/access-refresh.d.cts +1 -1
  26. package/dist/server/access-refresh.d.ts +1 -1
  27. package/dist/server/index.cjs +15 -5
  28. package/dist/server/index.js +15 -5
  29. package/dist/server/types.d.cts +2 -1
  30. package/dist/server/types.d.ts +2 -1
  31. package/dist/space-client/media-projection-monitor.d.ts +26 -0
  32. package/dist/space-client/promise-facade.d.ts +5 -5
  33. package/dist/space-client/types.d.ts +4 -0
  34. package/dist/transport-diagnostics.d.ts +1 -1
  35. package/package.json +1 -1
@@ -1,12 +1,16 @@
1
1
  import {
2
2
  CloudflareSFUClient,
3
+ CloudflareSFUError,
4
+ classifyMediaFailure,
3
5
  createCloudflareSFUHTTPTransport,
4
- describeMediaCaptureError
5
- } from "./chunk-3ASKGMD3.js";
6
+ describeMediaCaptureError,
7
+ grantedMediaFromStream,
8
+ markMediaFailure
9
+ } from "./chunk-YIDKETOX.js";
6
10
  import {
7
11
  AccessGrantError,
8
12
  requireParsedAccessGrant
9
- } from "./chunk-AZONR3Q3.js";
13
+ } from "./chunk-CJVLTSIO.js";
10
14
  import {
11
15
  ChatAttachmentMimeTypeSchema,
12
16
  ChatReactionSetFrameSchema,
@@ -23,19 +27,43 @@ import {
23
27
  } from "./chunk-5YPQNMJE.js";
24
28
 
25
29
  // src/access/manager.ts
26
- import { Clock, Context, Data, Effect, Layer, SynchronizedRef } from "effect";
30
+ import { Clock, Context, Data, Deferred, Effect, Layer, SynchronizedRef } from "effect";
27
31
  var DEFAULT_REFRESH_WINDOW_MS = 6e4;
28
32
  var ACCESS_PROVIDER_TIMEOUT_MS = 7e3;
29
33
  var ConnectionAccessFailure = class extends Data.TaggedError("ConnectionAccessFailure") {
30
34
  };
35
+ var MediaReplacementPending = class extends Data.TaggedError("MediaReplacementPending") {
36
+ };
31
37
  var ConnectionAccessService = class extends Context.Service()("@chalk/client/ConnectionAccess") {
32
38
  };
33
39
  var makeConnectionAccessLayer = (provider, refreshWindowMs = DEFAULT_REFRESH_WINDOW_MS) => Layer.effect(
34
40
  ConnectionAccessService,
35
41
  Effect.gen(function* () {
36
42
  const state = yield* SynchronizedRef.make({ access: null });
43
+ const holds = /* @__PURE__ */ new Map();
44
+ const replacementGate = () => {
45
+ const binding = SynchronizedRef.getUnsafe(state).access?.media.clientPayload.connectionId;
46
+ return binding ? holds.get(binding)?.done : void 0;
47
+ };
48
+ const holdMediaBinding = (binding) => Effect.gen(function* () {
49
+ const hold = holds.get(binding) ?? { count: 0, done: yield* Deferred.make() };
50
+ hold.count++;
51
+ holds.set(binding, hold);
52
+ return Effect.gen(function* () {
53
+ if (--hold.count !== 0) return;
54
+ holds.delete(binding);
55
+ yield* Deferred.succeed(hold.done, void 0);
56
+ });
57
+ });
37
58
  const automaticMediaReplacementListeners = /* @__PURE__ */ new Set();
38
- const fetch2 = (reason, replaceMediaConnection, force) => SynchronizedRef.modifyEffect(state, (current) => fetchAccessGrant(provider, { current, reason, replaceMediaConnection, force, refreshWindowMs })).pipe(
59
+ const fetch2 = (reason, replaceMediaConnection, force) => SynchronizedRef.modifyEffect(state, (current) => fetchAccessGrant(provider, { current, reason, replaceMediaConnection, force, refreshWindowMs, replacementGate })).pipe(
60
+ Effect.catchTag(
61
+ "MediaReplacementPending",
62
+ ({ done }) => Deferred.await(done).pipe(
63
+ Effect.flatMap(() => fetch2(reason, replaceMediaConnection, force)),
64
+ Effect.map((access) => ({ access, automaticMediaReplacement: false }))
65
+ )
66
+ ),
39
67
  Effect.tap((result) => Effect.sync(() => notifyAutomaticMediaReplacement(automaticMediaReplacementListeners, result))),
40
68
  Effect.map((result) => result.access)
41
69
  );
@@ -61,7 +89,12 @@ var makeConnectionAccessLayer = (provider, refreshWindowMs = DEFAULT_REFRESH_WIN
61
89
  }),
62
90
  requiresRefresh: millisecondsUntilRefresh.pipe(Effect.map((milliseconds2) => milliseconds2 === null || milliseconds2 === 0)),
63
91
  millisecondsUntilRefresh,
64
- clear: SynchronizedRef.set(state, { access: null })
92
+ holdMediaBinding,
93
+ clear: Effect.gen(function* () {
94
+ yield* SynchronizedRef.set(state, { access: null });
95
+ for (const hold of holds.values()) yield* Deferred.fail(hold.done, new ConnectionAccessFailure({ code: "access.unavailable", cause: "Connection closed during media rejection classification" }));
96
+ holds.clear();
97
+ })
65
98
  };
66
99
  })
67
100
  );
@@ -70,17 +103,21 @@ function fetchAccessGrant(provider, input) {
70
103
  return Effect.gen(function* () {
71
104
  const now = yield* Clock.currentTimeMillis;
72
105
  const currentAccess = input.current.access;
73
- if (!input.force && currentAccess && millisecondsUntil(currentAccess, now, input.refreshWindowMs) > 0) {
106
+ if (canReuseAccess(input, now) && currentAccess) {
74
107
  return [{ access: currentAccess, automaticMediaReplacement: false }, input.current];
75
108
  }
76
- const mediaExpired = currentAccess !== null && expiresAt(currentAccess.media.expiresAt) <= now;
77
- const shouldReplaceMediaConnection = input.replaceMediaConnection || mediaExpired;
109
+ const gate = input.replacementGate();
110
+ const shouldReplaceMediaConnection = mediaReplacementRequested(input, now, Boolean(gate));
111
+ if (shouldReplaceMediaConnection && gate) return yield* Effect.fail(new MediaReplacementPending({ done: gate }));
78
112
  const next = yield* provider(connectionAccessRequest(input, shouldReplaceMediaConnection)).pipe(
79
113
  Effect.timeout(ACCESS_PROVIDER_TIMEOUT_MS),
80
114
  Effect.mapError((cause) => cause instanceof ConnectionAccessFailure ? cause : new ConnectionAccessFailure({ code: "access.unavailable", cause }))
81
115
  );
82
116
  yield* Effect.try({
83
- try: () => validateFetchedAccess(currentAccess, next, now, shouldReplaceMediaConnection),
117
+ try: () => {
118
+ validateFetchedAccess(currentAccess, next, now, shouldReplaceMediaConnection);
119
+ validateHeldBinding(input, next);
120
+ },
84
121
  catch: (cause) => new ConnectionAccessFailure({ code: "access.invalid", cause })
85
122
  });
86
123
  return [{ access: next, automaticMediaReplacement: currentAccess !== null && !input.replaceMediaConnection && !hasSameMediaConnection(currentAccess, next) }, { access: next }];
@@ -92,7 +129,8 @@ function connectionAccessRequest(input, replaceMediaConnection) {
92
129
  replaceMediaConnection,
93
130
  ...input.current.access ? {
94
131
  currentMediaToken: input.current.access.media.token,
95
- expectedParticipantGeneration: input.current.access.subject.participantGeneration
132
+ expectedParticipantGeneration: input.current.access.subject.participantGeneration,
133
+ ...replaceMediaConnection && input.reason !== "access_retry" ? { expectedMediaConnectionId: input.current.access.media.clientPayload.connectionId } : {}
96
134
  } : {}
97
135
  });
98
136
  }
@@ -138,6 +176,17 @@ function expiresAt(value) {
138
176
  if (!Number.isFinite(timestamp)) throw new TypeError("Access grant expiration is invalid");
139
177
  return timestamp;
140
178
  }
179
+ function canReuseAccess(input, now) {
180
+ return !input.force && input.current.access !== null && millisecondsUntil(input.current.access, now, input.refreshWindowMs) > 0;
181
+ }
182
+ function mediaReplacementRequested(input, now, held) {
183
+ const current = input.current.access;
184
+ return input.replaceMediaConnection || current !== null && expiresAt(current.media.expiresAt) <= now && !held;
185
+ }
186
+ function validateHeldBinding(input, next) {
187
+ const previous = input.current.access;
188
+ if (input.replacementGate() && previous && !hasSameMediaConnection(previous, next)) throw new TypeError("Cannot replace a rejected media binding before classification");
189
+ }
141
190
 
142
191
  // src/connection/dependencies.ts
143
192
  import { Context as Context2, Layer as Layer2 } from "effect";
@@ -2288,6 +2337,28 @@ function createChalkWhiteboardV1Client(options) {
2288
2337
  });
2289
2338
  }
2290
2339
 
2340
+ // src/connection/episode-status.ts
2341
+ var EpisodeStatusError = class extends Error {
2342
+ constructor(status, retryAfterMs = 0) {
2343
+ super(`Episode status unavailable: HTTP ${status}`);
2344
+ this.status = status;
2345
+ this.retryAfterMs = retryAfterMs;
2346
+ }
2347
+ status;
2348
+ retryAfterMs;
2349
+ };
2350
+ function episodeStatusRetryDelay(attempt, error2) {
2351
+ const retryAfterMs = error2 instanceof EpisodeStatusError ? error2.retryAfterMs : 0;
2352
+ const ceiling = Math.min(1e3 * 2 ** Math.min(attempt, 5), 1e4);
2353
+ return Math.max(retryAfterMs, ceiling * (0.5 + Math.random() * 0.5));
2354
+ }
2355
+ function retryAfterMilliseconds(value, now) {
2356
+ if (!value) return 0;
2357
+ const seconds = Number(value);
2358
+ const delay = Number.isFinite(seconds) ? seconds * 1e3 : Date.parse(value) - now;
2359
+ return Number.isFinite(delay) ? Math.max(0, delay) : 0;
2360
+ }
2361
+
2291
2362
  // src/connection/report.ts
2292
2363
  var MAX_EVENTS = 64;
2293
2364
  var MAX_BYTES = 32768;
@@ -2296,6 +2367,16 @@ var COOLDOWN_MS = 6e4;
2296
2367
  var participantCooldowns = /* @__PURE__ */ new Map();
2297
2368
  var milliseconds = (value) => Math.max(0, Math.min(MAX_MS, Math.round(Number.isFinite(value) ? value : 0)));
2298
2369
  var optionalMilliseconds = (value) => value == null ? void 0 : milliseconds(value);
2370
+ function mediaReportFields(media) {
2371
+ if (!media) return {};
2372
+ return {
2373
+ media_error_kind: media.media_error_kind,
2374
+ media_error_step: media.media_error_step,
2375
+ granted_audio: media.granted_audio,
2376
+ granted_video: media.granted_video,
2377
+ http_status: media.http_status
2378
+ };
2379
+ }
2299
2380
  function reserveParticipantReport(access, now) {
2300
2381
  const key = `${access.subject.tenantId}:${access.subject.participantId}`;
2301
2382
  for (const [participant, until] of participantCooldowns) if (until <= now) participantCooldowns.delete(participant);
@@ -2328,7 +2409,8 @@ var ConnectionReporter = class {
2328
2409
  attempt: optionalMilliseconds(diagnostic.attempt),
2329
2410
  duration_ms: optionalMilliseconds(diagnostic.sync?.durationMs ?? diagnostic.durationMs),
2330
2411
  close_code: diagnostic.sync?.closeCode ?? void 0,
2331
- heartbeat_gap_ms: optionalMilliseconds(diagnostic.recovery?.lastHeartbeatReplyAgoMs)
2412
+ heartbeat_gap_ms: optionalMilliseconds(diagnostic.recovery?.lastHeartbeatReplyAgoMs),
2413
+ ...mediaReportFields(diagnostic.media)
2332
2414
  });
2333
2415
  if (diagnostic.event === "recovery_exhausted") this.#report("recovery_exhausted");
2334
2416
  else if (diagnostic.event === "connection_failed") this.#report(diagnostic.state === "joining" ? "join_failed" : "connection_lost");
@@ -2342,8 +2424,13 @@ var ConnectionReporter = class {
2342
2424
  ...event.duration_ms == null ? {} : { duration_ms: milliseconds(event.duration_ms) },
2343
2425
  ...event.heartbeat_gap_ms == null ? {} : { heartbeat_gap_ms: milliseconds(event.heartbeat_gap_ms) },
2344
2426
  ...event.close_code == null ? {} : { close_code: event.close_code },
2345
- ...event.http_status == null ? {} : { http_status: event.http_status }
2427
+ ...event.http_status == null ? {} : { http_status: event.http_status },
2428
+ source: event.source,
2429
+ publication_state: event.publication_state,
2430
+ projection_state: event.projection_state,
2431
+ outcome: event.outcome
2346
2432
  });
2433
+ if (event.event === "media_projection_mismatch") this.#report("media_projection_mismatch");
2347
2434
  }
2348
2435
  dispose() {
2349
2436
  this.#disposed = true;
@@ -2427,7 +2514,12 @@ function createBrowserMediaDevices(mediaDevices = globalThis.navigator?.mediaDev
2427
2514
  return {
2428
2515
  async getUserMedia(constraints) {
2429
2516
  if (!mediaDevices?.getUserMedia || globalThis.isSecureContext === false) throw new ConnectionError({ code: "unsupported_environment", action: null, recoverable: false, message: "Browser media capture is unavailable. Use a supported browser over HTTPS." });
2430
- return mediaDevices.getUserMedia(constraints);
2517
+ try {
2518
+ return await mediaDevices.getUserMedia(constraints);
2519
+ } catch (error2) {
2520
+ markMediaFailure(error2, "local_capture");
2521
+ throw error2;
2522
+ }
2431
2523
  },
2432
2524
  async getDisplayMedia(constraints) {
2433
2525
  if (!mediaDevices?.getDisplayMedia) throw new DOMException("Browser display capture is unavailable", "NotSupportedError");
@@ -2506,6 +2598,20 @@ function createDefaultConnectionDependencies(options) {
2506
2598
  });
2507
2599
  },
2508
2600
  mediaDevices: createBrowserMediaDevices(),
2601
+ getEpisodeStatus: async (access, signal) => {
2602
+ const { tenantId, spaceId, episodeId, participantId } = access.subject;
2603
+ const path = [tenantId, spaceId, episodeId, participantId].map(encodeURIComponent);
2604
+ const response = await fetch(`${options.apiBaseURL.replace(/\/$/, "")}/v1/tenants/${path[0]}/spaces/${path[1]}/episodes/${path[2]}/participants/${path[3]}/episode-status`, {
2605
+ headers: { Authorization: `Bearer ${access.media.token}` },
2606
+ signal,
2607
+ cache: "no-store",
2608
+ credentials: "omit"
2609
+ });
2610
+ if (!response.ok) throw new EpisodeStatusError(response.status, retryAfterMilliseconds(response.headers.get("Retry-After"), Date.now()));
2611
+ const body = await response.json();
2612
+ if (typeof body === "object" && body !== null && "status" in body && (body.status === "active" || body.status === "ending" || body.status === "ended")) return body.status;
2613
+ throw new TypeError("Invalid Episode status");
2614
+ },
2509
2615
  cancelPendingAdmission: async (access, signal) => {
2510
2616
  const { tenantId, spaceId, episodeId, participantId } = access.subject;
2511
2617
  const path = [tenantId, spaceId, episodeId, participantId].map(encodeURIComponent);
@@ -2593,6 +2699,7 @@ function createSFUConnectionMediaClient(client) {
2593
2699
  function bindConnectionMediaClient(client, restart) {
2594
2700
  return {
2595
2701
  setLocalPublicationTarget: client.setLocalPublicationTarget.bind(client),
2702
+ ...client.pauseLocalCapture ? { pauseLocalCapture: client.pauseLocalCapture.bind(client) } : {},
2596
2703
  ...client.setLocalSourceIntent ? { setLocalSourceIntent: client.setLocalSourceIntent.bind(client) } : {},
2597
2704
  ...client.closeForcedLocalPublication ? { closeForcedLocalPublication: client.closeForcedLocalPublication.bind(client) } : {},
2598
2705
  observeLocalPublications: client.observeLocalPublications.bind(client),
@@ -2665,7 +2772,7 @@ function scheduleForegroundDeadline(clock, milliseconds2, expired) {
2665
2772
  }
2666
2773
 
2667
2774
  // src/connection/lifecycle.ts
2668
- import { Clock as Clock2, Context as Context3, Data as Data2, Deferred, Duration, Effect as Effect2, Exit, Fiber, Layer as Layer3, Queue, Scope, SubscriptionRef } from "effect";
2775
+ import { Clock as Clock2, Context as Context3, Data as Data2, Deferred as Deferred2, Duration, Effect as Effect2, Exit, Fiber, Layer as Layer3, Queue, Scope, SubscriptionRef } from "effect";
2669
2776
 
2670
2777
  // src/connection/diagnostics.ts
2671
2778
  var ConnectionDiagnostics = class {
@@ -2690,11 +2797,17 @@ var ConnectionDiagnostics = class {
2690
2797
  }
2691
2798
  recordRecoveryExhausted(input) {
2692
2799
  const { state, epoch, code, attempts, transport } = input;
2800
+ const media = input.media ?? (code === "media_recovery_exhausted" ? {
2801
+ media_error_kind: "timeout",
2802
+ media_error_step: "peer_connection",
2803
+ ...input.grantedMedia
2804
+ } : void 0);
2693
2805
  this.record({
2694
2806
  event: "recovery_exhausted",
2695
2807
  state,
2696
2808
  epoch,
2697
2809
  code,
2810
+ ...media ? { media } : {},
2698
2811
  recovery: {
2699
2812
  reference: `connection.${code}`,
2700
2813
  attempts,
@@ -2726,7 +2839,8 @@ var ConnectionDiagnostics = class {
2726
2839
  ...input.parentSpanId ? { parentSpanId: input.parentSpanId } : {},
2727
2840
  outcome: end.outcome,
2728
2841
  durationMs: Math.max(0, this.#now() - startedAt),
2729
- ...end.code ? { code: end.code } : {}
2842
+ ...end.code ? { code: end.code } : {},
2843
+ ...end.media ? { media: end.media } : {}
2730
2844
  });
2731
2845
  }
2732
2846
  };
@@ -2740,8 +2854,7 @@ var ConnectionDiagnostics = class {
2740
2854
  var MEDIA_RECOVERY_TIMEOUT_MS = 6e4;
2741
2855
  var START_TIMEOUT_MS = 3e4;
2742
2856
  var LEAVE_TIMEOUT_MS = 5e3;
2743
- var RECOVERY_BUDGET_MS = 1e4;
2744
- var SYNC_RECOVERY_BUDGET_MS = MEDIA_RECOVERY_TIMEOUT_MS;
2857
+ var RECOVERY_BUDGET_MS = MEDIA_RECOVERY_TIMEOUT_MS;
2745
2858
  var MAX_RECOVERY_ATTEMPTS = 3;
2746
2859
  var REFRESH_RETRY_MS = 5e3;
2747
2860
  var RECONNECT_GRACE_MS = 250;
@@ -2783,6 +2896,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2783
2896
  const context = yield* Effect2.context();
2784
2897
  const serviceScope = yield* Effect2.scope;
2785
2898
  const reporter = platform.createConnectionReporter?.(access.currentUnsafe);
2899
+ let grantedMedia = { granted_audio: "unknown", granted_video: "unknown" };
2900
+ let mediaFailure;
2786
2901
  const diagnostics = new ConnectionDiagnostics({
2787
2902
  now: () => clock.currentTimeMillisUnsafe(),
2788
2903
  ...options.diagnostics,
@@ -2802,6 +2917,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2802
2917
  let fallbackIdentifier = 0;
2803
2918
  let emittedPorts = null;
2804
2919
  let foregroundRecoveryDeadline = 0;
2920
+ let pendingEpisodeEnd = null;
2921
+ let rejectionCheck = null;
2805
2922
  const model = {
2806
2923
  state: "idle",
2807
2924
  sync: null,
@@ -2833,7 +2950,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2833
2950
  epoch: 0
2834
2951
  };
2835
2952
  const mediaAttempt = { expiresAt: null };
2836
- const foreign7 = (operation, code = "internal_error", message = "A browser transport operation failed") => Effect2.tryPromise({ try: operation, catch: (cause) => accessRejected(cause) ? lifecycleFailure("invalid_access", false, "Access was rejected", cause) : lifecycleFailure(code, true, message, cause) });
2953
+ const foreign7 = (operation, code = "internal_error", message = "A browser transport operation failed") => Effect2.tryPromise({ try: operation, catch: (cause) => accessRejected(cause) ? lifecycleFailure("invalid_access", false, "Access was rejected", cause) : sfuAccessRejected(cause) ? failureFromCause(cause, code, message) : lifecycleFailure(code, true, message, cause) });
2837
2954
  const withinForegroundBudget = (effect, milliseconds2, failure2) => Effect2.raceFirst(
2838
2955
  effect,
2839
2956
  Effect2.callback((resume) => {
@@ -2882,10 +2999,10 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2882
2999
  yield* publish();
2883
3000
  });
2884
3001
  const enqueue = (effect, join = false) => Effect2.gen(function* () {
2885
- if (model.closed) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed"));
2886
- const deferred = yield* Deferred.make();
3002
+ if (model.closed) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed. Rejoin the call."));
3003
+ const deferred = yield* Deferred2.make();
2887
3004
  yield* Queue.offer(queue, { effect, deferred, join });
2888
- return yield* Deferred.await(deferred);
3005
+ return yield* Deferred2.await(deferred);
2889
3006
  });
2890
3007
  const enqueueBackground = (effect) => {
2891
3008
  void Effect2.runForkWith(context)(enqueue(effect).pipe(Effect2.ignore));
@@ -3041,7 +3158,13 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3041
3158
  const span = diagnostics.startSpan({ step, state: model.state, epoch: model.epoch });
3042
3159
  return effect.pipe(
3043
3160
  Effect2.tap(() => Effect2.sync(() => span.end({ state: model.state, epoch: model.epoch, outcome: "succeeded" }))),
3044
- Effect2.tapError((cause) => Effect2.sync(() => span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failureFromCause(cause, "internal_error", "Connection step failed").code })))
3161
+ Effect2.tapError(
3162
+ (cause) => Effect2.sync(() => {
3163
+ const media = step === "acquire_initial_media" || step === "create_media_client" || step === "start_media" ? { ...classifyMediaFailure(cause, step === "acquire_initial_media" ? "local_capture" : "peer_connection"), ...grantedMedia } : void 0;
3164
+ if (media) mediaFailure = media;
3165
+ span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failureFromCause(cause, "internal_error", "Connection step failed").code, ...media ? { media } : {} });
3166
+ })
3167
+ )
3045
3168
  );
3046
3169
  };
3047
3170
  const confirmSyncEntry = (sync) => {
@@ -3052,6 +3175,10 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3052
3175
  if (model.state === "live") return;
3053
3176
  if (model.state === "leaving" || model.closed) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Cannot join an inactive Connection"));
3054
3177
  model.epoch += 1;
3178
+ pendingEpisodeEnd = null;
3179
+ rejectionCheck = null;
3180
+ grantedMedia = { granted_audio: "unknown", granted_video: "unknown" };
3181
+ mediaFailure = void 0;
3055
3182
  model.failure = null;
3056
3183
  model.episodeEndConfirmed = false;
3057
3184
  model.episodeEndReason = void 0;
@@ -3066,6 +3193,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3066
3193
  if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
3067
3194
  const captured = yield* trace("acquire_initial_media", acquireInitialMedia(model.initialMedia, model.initialMediaIntent?.() ?? model.intent));
3068
3195
  stream = captured.stream;
3196
+ grantedMedia = grantedMediaFromStream(stream);
3069
3197
  captureWarning = captured.failure;
3070
3198
  if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
3071
3199
  const grant = yield* trace("access_initialize", reason === "access_retry" ? access.refreshAfterRejection() : access.initialize(reason));
@@ -3082,7 +3210,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3082
3210
  }),
3083
3211
  onTransportDiagnostic,
3084
3212
  traceparent: reporter?.traceparent,
3085
- onFailure: () => enqueueBackground(handleMediaFailure()),
3213
+ onFailure: (cause, binding = grant.media.clientPayload.connectionId) => enqueueBackground(handleMediaFailure(media, cause, binding)),
3086
3214
  onScreenEnded: () => enqueueBackground(notifyScreenEnded())
3087
3215
  }),
3088
3216
  catch: (cause) => accessRejected(cause) ? lifecycleFailure("invalid_access", false, "Access was rejected", cause) : lifecycleFailure("media_start_failed", true, "The media layer could not start", cause)
@@ -3102,7 +3230,11 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3102
3230
  [
3103
3231
  trace(
3104
3232
  "start_media",
3105
- model.startInitialMedia ? model.startInitialMedia(media, stream).pipe(Effect2.mapError((cause) => lifecycleFailure("media_start_failed", true, "The media layer could not start", cause))) : foreign7(() => media.start(stream), "media_start_failed", "The media layer could not start")
3233
+ (model.startInitialMedia ? model.startInitialMedia(media, stream) : Effect2.tryPromise({ try: () => media.start(stream), catch: (cause) => cause })).pipe(
3234
+ Effect2.mapError(
3235
+ (cause) => sfuAccessRejected(cause) ? lifecycleFailure("invalid_access", false, "Media access was rejected. Another tab or device may be using it. Rejoin explicitly to request access again.", cause) : model.startInitialMedia ? lifecycleFailure("media_start_failed", true, "The media layer could not start", cause) : failureFromCause(cause, "media_start_failed", "The media layer could not start")
3236
+ )
3237
+ )
3106
3238
  ),
3107
3239
  trace(
3108
3240
  "start_sync",
@@ -3120,14 +3252,20 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3120
3252
  Effect2.matchEffect({
3121
3253
  onFailure: (cause) => Effect2.gen(function* () {
3122
3254
  stopStream(stream);
3123
- const failure2 = failureFromCause(cause, "internal_error", "The Connection could not start");
3255
+ const originalFailure = failureFromCause(cause, "internal_error", "The Connection could not start");
3256
+ const failure2 = yield* resolveMediaRejection(originalFailure);
3257
+ if (failure2.code === "episode_ended" && needsEpisodeCheck(originalFailure)) {
3258
+ span.end({ state: model.state, epoch: model.epoch, outcome: "cancelled", code: "episode_ended" });
3259
+ yield* finishEpisodeEnded();
3260
+ return;
3261
+ }
3124
3262
  model.failure = toFailure(failure2);
3125
3263
  if (model.joinCancelled) {
3126
3264
  span.end({ state: model.state, epoch: model.epoch, outcome: "cancelled", code: "invalid_state" });
3127
3265
  return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
3128
3266
  }
3129
3267
  span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failure2.code });
3130
- const retry = !retried && failure2.code === "invalid_access";
3268
+ const retry = !retried && failure2.code === "invalid_access" && !sfuAccessRejected(failure2.cause);
3131
3269
  if (!retry) yield* recordFailure(failure2);
3132
3270
  model.admissionCleanupConfirmed = yield* cancelPendingAdmission();
3133
3271
  yield* stopPorts();
@@ -3191,7 +3329,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3191
3329
  yield* scheduleRefresh();
3192
3330
  })
3193
3331
  );
3194
- const requireOpen = Effect2.suspend(() => model.closed ? Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed")) : Effect2.void);
3332
+ const requireOpen = Effect2.suspend(() => model.closed ? Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed. Rejoin the call.")) : Effect2.void);
3195
3333
  const withFreshAccess = (operation) => Effect2.gen(function* () {
3196
3334
  yield* requireOpen;
3197
3335
  if (yield* access.requiresRefresh) {
@@ -3272,7 +3410,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3272
3410
  }
3273
3411
  const code = plan.kind === "media" ? "media_recovery_exhausted" : "sync_recovery_exhausted";
3274
3412
  const transport = model.sync?.getSnapshot().connection.recoveryDiagnostic ?? null;
3275
- diagnostics.recordRecoveryExhausted({ state: model.state, epoch, code, attempts, transport });
3413
+ diagnostics.recordRecoveryExhausted({ state: model.state, epoch, code, attempts, transport, media: mediaFailure, grantedMedia });
3276
3414
  if (plan.kind === "sync") {
3277
3415
  yield* failForSnapshot(lifecycleFailure(code, true, "Connection lost. Rejoin to continue."));
3278
3416
  return;
@@ -3293,13 +3431,13 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3293
3431
  const recoveryPlan = (kind) => Clock2.currentTimeMillis.pipe(
3294
3432
  Effect2.map((now) => ({
3295
3433
  kind,
3296
- deadline: now + boundedInteger(options.recovery?.budgetMs, kind === "sync" ? SYNC_RECOVERY_BUDGET_MS : RECOVERY_BUDGET_MS, 1, 6e4),
3297
- attempts: boundedInteger(options.recovery?.maxAttempts, MAX_RECOVERY_ATTEMPTS, 1, 10),
3298
- delays: options.recovery?.backoffMs?.length ? options.recovery.backoffMs : [100, 250, 500]
3434
+ deadline: now + boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4),
3435
+ attempts: kind === "sync" ? boundedInteger(options.recovery?.maxAttempts, MAX_RECOVERY_ATTEMPTS, 1, 10) : null,
3436
+ delays: options.recovery?.backoffMs?.length ? options.recovery.backoffMs : kind === "media" ? [1e3, 2e3, 4e3, 6e3] : [100, 250, 500]
3299
3437
  }))
3300
3438
  );
3301
3439
  const attemptRecovery = (plan, mediaAttempt2, syncAttempt, onAttempt) => Effect2.gen(function* () {
3302
- for (let attempt = 1; attempt <= plan.attempts; attempt += 1) {
3440
+ for (let attempt = 1; plan.attempts === null || attempt <= plan.attempts; attempt += 1) {
3303
3441
  const remaining = plan.deadline - (yield* Clock2.currentTimeMillis);
3304
3442
  if (remaining <= 0) return null;
3305
3443
  onAttempt();
@@ -3310,7 +3448,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3310
3448
  );
3311
3449
  if (failure2 === null) return attempt;
3312
3450
  if (!failure2.recoverable) return yield* Effect2.fail(failure2);
3313
- if (attempt < plan.attempts) {
3451
+ if (plan.attempts === null || attempt < plan.attempts) {
3314
3452
  const remaining2 = Math.max(0, plan.deadline - (yield* Clock2.currentTimeMillis));
3315
3453
  yield* Effect2.sleep(Math.min(recoveryDelay(plan, attempt), remaining2));
3316
3454
  }
@@ -3327,10 +3465,13 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3327
3465
  });
3328
3466
  const recoveryDelay = (plan, attempt) => {
3329
3467
  const delay = plan.delays[Math.min(attempt - 1, plan.delays.length - 1)] ?? 0;
3330
- return Number.isFinite(delay) ? Math.max(0, delay) : 0;
3468
+ const finiteDelay = Number.isFinite(delay) ? Math.max(0, delay) : 0;
3469
+ const mediaMinimum = [1e3, 2e3, 4e3][attempt - 1] ?? 6e3;
3470
+ return plan.kind === "media" ? Math.min(6e3, Math.max(mediaMinimum, finiteDelay)) : finiteDelay;
3331
3471
  };
3332
3472
  const finishRecovery = (attempt, epoch) => Effect2.gen(function* () {
3333
3473
  if (model.epoch !== epoch || !active(model)) return;
3474
+ mediaFailure = void 0;
3334
3475
  model.recoveryQueued = null;
3335
3476
  model.syncSnapshot = model.sync?.getSnapshot() ?? null;
3336
3477
  model.mediaSnapshot = model.media?.getSnapshot() ?? null;
@@ -3390,7 +3531,20 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3390
3531
  attempt.expiresAt = (yield* Clock2.currentTimeMillis) + MEDIA_RECOVERY_TIMEOUT_MS;
3391
3532
  yield* foreign7(() => media.restart(restartInput), "media_start_failed", "Media transport could not restart");
3392
3533
  yield* waitForMediaLive(media);
3393
- });
3534
+ }).pipe(
3535
+ Effect2.tapError(
3536
+ (cause) => Effect2.sync(() => {
3537
+ const step = cause.code === "access_unavailable" || cause.code === "invalid_access" ? "access_refresh" : "peer_connection";
3538
+ const snapshotDiagnostic = cause.cause === void 0 && step === "peer_connection" ? model.media?.getSnapshot().failure?.diagnostic : void 0;
3539
+ mediaFailure = { ...snapshotDiagnostic ?? classifyMediaFailure(cause, step), ...grantedMedia };
3540
+ })
3541
+ ),
3542
+ Effect2.tap(
3543
+ () => Effect2.sync(() => {
3544
+ mediaFailure = void 0;
3545
+ })
3546
+ )
3547
+ );
3394
3548
  const syncAttemptExpired = (attempt, sync, now) => {
3395
3549
  if (attempt.client !== sync) {
3396
3550
  attempt.client = sync;
@@ -3418,7 +3572,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3418
3572
  attempt.expiresAt = now + MEDIA_RECOVERY_TIMEOUT_MS;
3419
3573
  }
3420
3574
  if ((attempt.started || snapshot.connection.phase !== "idle") && (yield* syncNeedsReplacement(snapshot))) return yield* restartSync(attempt);
3421
- yield* waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, SYNC_RECOVERY_BUDGET_MS, 1, 6e4));
3575
+ yield* waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4));
3422
3576
  });
3423
3577
  const restartSync = (attempt) => Effect2.gen(function* () {
3424
3578
  const epoch = model.epoch;
@@ -3449,7 +3603,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3449
3603
  else if (attempt.client === sync) attempt.started = true;
3450
3604
  })
3451
3605
  ).pipe(
3452
- Effect2.andThen(waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, SYNC_RECOVERY_BUDGET_MS, 1, 6e4))),
3606
+ Effect2.andThen(waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4))),
3453
3607
  Effect2.andThen(Effect2.suspend(() => sync === model.sync && model.epoch === epoch && active(model) ? confirmSyncEntry(sync) : Effect2.fail(lifecycleFailure("invalid_state", false, "Sync recovery was superseded")))),
3454
3608
  Effect2.tapError(
3455
3609
  () => enqueue(
@@ -3502,14 +3656,111 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3502
3656
  const syncNeedsRecovery = (snapshot) => active(model) && (snapshot.connection.phase === "connecting" || snapshot.connection.phase === "recovering" || snapshot.connection.phase === "terminal");
3503
3657
  const mediaNeedsRecovery = (snapshot) => active(model) && (snapshot.connection.phase === "recovering" || snapshot.connection.phase === "failed" && snapshot.failure?.recoverable === true);
3504
3658
  const recordFailure = (failure2) => Effect2.sync(() => {
3505
- diagnostics.record({ event: "connection_failed", state: model.state, epoch: model.epoch, code: failure2.code, ...failure2.sync ? { sync: failure2.sync } : {} });
3659
+ diagnostics.record({ event: "connection_failed", state: model.state, epoch: model.epoch, code: failure2.code, ...failure2.sync ? { sync: failure2.sync } : {}, ...mediaFailure ? { media: mediaFailure } : {} });
3660
+ });
3661
+ const finishEpisodeEnded = () => Effect2.gen(function* () {
3662
+ model.episodeEndConfirmed = true;
3663
+ model.syncSnapshot = model.sync?.getSnapshot() ?? model.syncSnapshot;
3664
+ model.episodeEndReason = model.syncSnapshot?.control?.endReason;
3665
+ yield* finishTerminalSnapshot();
3506
3666
  });
3507
- const failForSnapshot = (failure2) => Effect2.gen(function* failForSnapshotEffect() {
3667
+ const needsEpisodeCheck = (failure2) => failure2.code === "invalid_access" || failure2.cause instanceof CloudflareSFUError && failure2.cause.options.status === 410;
3668
+ const episodeHasEnded = () => {
3669
+ const snapshot = model.sync?.getSnapshot();
3670
+ return model.episodeEndConfirmed || snapshot?.control?.status === "ended" || snapshot?.connection.terminalReason === "episode_ended";
3671
+ };
3672
+ const stopRejectedMedia = () => {
3673
+ model.mediaBindingCleanup?.();
3674
+ model.mediaSnapshot = model.media?.getSnapshot() ?? null;
3675
+ };
3676
+ const waitForPendingEpisodeEnd = Effect2.suspend(() => pendingEpisodeEnd ? Deferred2.await(pendingEpisodeEnd) : Effect2.void);
3677
+ const resolveMediaRejection = (failure2) => {
3678
+ if (!needsEpisodeCheck(failure2)) return Effect2.succeed(failure2);
3679
+ if (failure2.code === "invalid_access") stopRejectedMedia();
3680
+ const epoch = model.epoch;
3681
+ const original = access.currentUnsafe();
3682
+ if (!original) return Effect2.succeed(failure2);
3683
+ return checkMediaRejection(failure2, original, epoch);
3684
+ };
3685
+ const checkMediaRejection = (failure2, original, epoch) => Effect2.gen(function* () {
3686
+ let attempt = 0;
3687
+ let refreshedAfterAuthRejection = false;
3688
+ let rejectedToken = null;
3689
+ while (model.epoch === epoch) {
3690
+ yield* waitForPendingEpisodeEnd;
3691
+ if (episodeHasEnded()) return lifecycleFailure("episode_ended", false, "The Episode has ended");
3692
+ const current = access.currentUnsafe();
3693
+ const grant = sameBindingCredential(current, original);
3694
+ const result = yield* readEpisodeStatus(platform.getEpisodeStatus, grant, rejectedToken);
3695
+ yield* waitForPendingEpisodeEnd;
3696
+ if (episodeHasEnded() || statusIsEnded(result.status)) return lifecycleFailure("episode_ended", false, "The Episode has ended");
3697
+ if (result.status === "active") return failure2;
3698
+ const error2 = result.cause;
3699
+ if (statusCredentialsRejected(error2)) {
3700
+ rejectedToken = grant.media.token;
3701
+ if (!refreshedAfterAuthRejection) {
3702
+ refreshedAfterAuthRejection = true;
3703
+ yield* access.refresh("scheduled_refresh", false).pipe(Effect2.catch(() => Effect2.succeed(null)));
3704
+ continue;
3705
+ }
3706
+ }
3707
+ yield* Effect2.sleep(episodeStatusRetryDelay(attempt++, error2));
3708
+ }
3709
+ return lifecycleFailure("invalid_state", false, "The connection changed");
3710
+ }).pipe(
3711
+ Effect2.timeout(6e4),
3712
+ Effect2.catch(() => Effect2.succeed(episodeHasEnded() ? lifecycleFailure("episode_ended", false, "The Episode has ended") : lifecycleFailure("access_unavailable", true, "Could not confirm Episode status. Rejoin to try again.")))
3713
+ );
3714
+ const commitSnapshotFailure = (failure2) => Effect2.gen(function* () {
3715
+ if (failure2.code === "episode_ended") return yield* finishEpisodeEnded();
3508
3716
  model.failure = toFailure(failure2);
3509
3717
  yield* recordFailure(failure2);
3510
3718
  yield* stopPorts();
3511
3719
  yield* transition("failed");
3512
3720
  });
3721
+ const failForSnapshot = (failure2) => {
3722
+ if (!needsEpisodeCheck(failure2)) return commitSnapshotFailure(failure2);
3723
+ if (failure2.code === "invalid_access") stopRejectedMedia();
3724
+ const scope = model.activeScope;
3725
+ const epoch = model.epoch;
3726
+ const binding = access.currentUnsafe()?.media.clientPayload.connectionId;
3727
+ if (!scope) return Effect2.void;
3728
+ if (rejectionCheck?.epoch === epoch && rejectionCheck.binding === binding) {
3729
+ if (failure2.code === "invalid_access") rejectionCheck.failure = failure2;
3730
+ return publish();
3731
+ }
3732
+ const check = { epoch, binding, failure: failure2 };
3733
+ rejectionCheck = check;
3734
+ const releaseBinding = binding ? Effect2.runSync(access.holdMediaBinding(binding)) : Effect2.void;
3735
+ return Effect2.gen(function* () {
3736
+ if (model.epoch !== epoch || model.activeScope !== scope) return;
3737
+ yield* publish();
3738
+ yield* Effect2.forkIn(
3739
+ resolveMediaRejection(failure2).pipe(
3740
+ Effect2.flatMap(
3741
+ (resolved) => enqueue(
3742
+ Effect2.suspend(() => {
3743
+ if (model.epoch !== epoch || !active(model)) return Effect2.void;
3744
+ const finalFailure = resolved === failure2 ? check.failure : resolved;
3745
+ if (finalFailure === failure2 && failure2.cause instanceof CloudflareSFUError && failure2.cause.options.status === 410) {
3746
+ rejectionCheck = null;
3747
+ return recover("media");
3748
+ }
3749
+ return commitSnapshotFailure(finalFailure);
3750
+ })
3751
+ )
3752
+ ),
3753
+ Effect2.ensuring(
3754
+ Effect2.gen(function* () {
3755
+ if (rejectionCheck === check) rejectionCheck = null;
3756
+ if (model.activeScope === scope) yield* releaseBinding;
3757
+ })
3758
+ )
3759
+ ),
3760
+ scope
3761
+ );
3762
+ });
3763
+ };
3513
3764
  const handleMediaSnapshot = (media, snapshot) => Effect2.gen(function* () {
3514
3765
  if (media !== model.media || media.getSnapshot() !== snapshot) return;
3515
3766
  if (snapshot === model.mediaSnapshot) return;
@@ -3519,7 +3770,12 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3519
3770
  if (mediaNeedsRecovery(snapshot)) yield* recover("media");
3520
3771
  yield* publish();
3521
3772
  });
3522
- const handleMediaFailure = () => model.media ? handleMediaSnapshot(model.media, model.media.getSnapshot()) : Effect2.void;
3773
+ const handleMediaFailure = (media, cause, binding) => {
3774
+ if (media !== model.media || binding !== access.currentUnsafe()?.media.clientPayload.connectionId) return Effect2.void;
3775
+ if (sfuAccessRejected(cause)) stopRejectedMedia();
3776
+ if (sfuAccessRejected(cause) || cause instanceof CloudflareSFUError && cause.options.status === 410 || active(model) && connectionSuperseded(cause)) return failForSnapshot(failureFromCause(cause, "media_start_failed", "Media transport needs recovery"));
3777
+ return handleMediaSnapshot(media, media.getSnapshot());
3778
+ };
3523
3779
  const applyAutomaticMediaReplacement = (grant) => Effect2.gen(function* () {
3524
3780
  const media = model.media;
3525
3781
  const scope = model.activeScope;
@@ -3535,7 +3791,12 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3535
3791
  "media_start_failed",
3536
3792
  "The media client could not apply a replacement access grant"
3537
3793
  ).pipe(
3538
- (effect) => withinForegroundBudget(effect, MEDIA_RECOVERY_TIMEOUT_MS, lifecycleFailure("media_start_failed", true, "Automatic media replacement did not finish")),
3794
+ (effect) => withinForegroundBudget(effect, MEDIA_RECOVERY_TIMEOUT_MS, lifecycleFailure("media_start_failed", true, "Automatic media replacement did not finish", new CloudflareSFUError("Automatic media replacement deadline expired", "negotiation_timeout"))),
3795
+ Effect2.tapError(
3796
+ (cause) => Effect2.sync(() => {
3797
+ mediaFailure = { ...classifyMediaFailure(cause, "peer_connection"), ...grantedMedia };
3798
+ })
3799
+ ),
3539
3800
  Effect2.match({ onSuccess: () => true, onFailure: () => false }),
3540
3801
  Effect2.flatMap(
3541
3802
  (restarted) => enqueue(
@@ -3569,8 +3830,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3569
3830
  const exit = yield* Effect2.exit(Fiber.join(fiber));
3570
3831
  activeJoin = null;
3571
3832
  joinDeferred = null;
3572
- yield* Deferred.done(work.deferred, exit);
3573
- }) : Deferred.complete(work.deferred, work.effect).pipe(Effect2.asVoid);
3833
+ yield* Deferred2.done(work.deferred, exit);
3834
+ }) : Deferred2.complete(work.deferred, work.effect).pipe(Effect2.asVoid);
3574
3835
  yield* Effect2.forkScoped(Queue.take(queue).pipe(Effect2.flatMap(process), Effect2.forever));
3575
3836
  const accessReplacementUnsubscribe = yield* access.subscribeAutomaticMediaReplacement((grant) => enqueueBackground(applyAutomaticMediaReplacement(grant)));
3576
3837
  const foregroundUnsubscribe = platform.subscribeForeground?.(() => {
@@ -3620,15 +3881,33 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3620
3881
  confirmEpisodeEnded: Effect2.sync(() => {
3621
3882
  model.episodeEndConfirmed = true;
3622
3883
  }),
3884
+ runEpisodeEnd: (operation) => Effect2.gen(function* () {
3885
+ const completed = yield* Deferred2.make();
3886
+ const epoch = model.epoch;
3887
+ pendingEpisodeEnd = completed;
3888
+ return yield* operation.pipe(
3889
+ Effect2.tap(
3890
+ () => Effect2.sync(() => {
3891
+ if (model.epoch === epoch) model.episodeEndConfirmed = true;
3892
+ })
3893
+ ),
3894
+ Effect2.ensuring(
3895
+ Effect2.gen(function* () {
3896
+ if (pendingEpisodeEnd === completed) pendingEpisodeEnd = null;
3897
+ yield* Deferred2.succeed(completed, void 0);
3898
+ })
3899
+ )
3900
+ );
3901
+ }),
3623
3902
  join: () => Effect2.suspend(() => {
3624
3903
  if (model.state === "live") return Effect2.void;
3625
- if (joinDeferred) return Deferred.await(joinDeferred);
3904
+ if (joinDeferred) return Deferred2.await(joinDeferred);
3626
3905
  return Effect2.gen(function* () {
3627
- const deferred = yield* Deferred.make();
3906
+ const deferred = yield* Deferred2.make();
3628
3907
  model.joinCancelled = false;
3629
3908
  joinDeferred = deferred;
3630
3909
  yield* Queue.offer(queue, { effect: performJoin(), deferred, join: true });
3631
- return yield* Deferred.await(deferred);
3910
+ return yield* Deferred2.await(deferred);
3632
3911
  });
3633
3912
  }),
3634
3913
  leave: () => Effect2.gen(function* () {
@@ -3656,7 +3935,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3656
3935
  nowUnsafe: () => clock.currentTimeMillisUnsafe(),
3657
3936
  scheduleUnsafe: (callback, milliseconds2) => platform.clock.setTimeout(callback, milliseconds2),
3658
3937
  cancelScheduleUnsafe: (handle) => platform.clock.clearTimeout(handle),
3659
- createId: () => platform.createId?.() ?? `local-${++fallbackIdentifier}`
3938
+ createId: () => platform.createId?.() ?? `local-${++fallbackIdentifier}`,
3939
+ recordTransportDiagnostic: onTransportDiagnostic
3660
3940
  };
3661
3941
  })
3662
3942
  );
@@ -3701,7 +3981,12 @@ function toFailure(value) {
3701
3981
  return Object.freeze({ code: value.code, action: null, recoverable: value.recoverable, message: value.message, ...value.sync ? { sync: value.sync } : {} });
3702
3982
  }
3703
3983
  function accessFailure(value) {
3704
- return lifecycleFailure(value.code === "access.invalid" ? "invalid_access" : "access_unavailable", value.code === "access.unavailable", value.code === "access.invalid" ? "Access was rejected" : "Could not reach the Space. Check your network and try again.", value.cause);
3984
+ const rejectedMessage = connectionSuperseded(value) ? "Access was rejected. Another tab or device may be using it. Rejoin explicitly to continue." : "Access was rejected";
3985
+ return lifecycleFailure(value.code === "access.invalid" ? "invalid_access" : "access_unavailable", value.code === "access.unavailable", value.code === "access.invalid" ? rejectedMessage : "Could not reach the Space. Check your network and try again.", value.cause);
3986
+ }
3987
+ function connectionSuperseded(failure2) {
3988
+ const cause = failure2 instanceof ConnectionAccessFailure ? failure2.cause : failure2;
3989
+ return cause instanceof AccessGrantError ? cause.reason === "connection_superseded" : typeof cause === "object" && cause !== null && "code" in cause && cause.code === "participant.connection_superseded";
3705
3990
  }
3706
3991
  function captureFailure(cause) {
3707
3992
  if (cause instanceof ConnectionError && cause.cause === void 0) return lifecycleFailure(cause.code, cause.recoverable, cause.message, cause);
@@ -3757,15 +4042,19 @@ function initialCaptureWarning(failures) {
3757
4042
  return lifecycleFailure(first.failure.code, true, message, first.failure.cause);
3758
4043
  }
3759
4044
  function failureFromCause(cause, code, message) {
4045
+ if (sfuAccessRejected(cause)) return lifecycleFailure("invalid_access", false, "Media access was rejected. Rejoin explicitly to continue.", cause);
3760
4046
  if (cause instanceof ConnectionLifecycleFailure) return cause;
3761
4047
  if (cause instanceof ConnectionAccessFailure) return accessFailure(cause);
3762
- if (cause instanceof AccessGrantError) return lifecycleFailure("invalid_access", false, "Access was rejected", cause);
4048
+ if (cause instanceof AccessGrantError || connectionSuperseded(cause)) return accessFailure(new ConnectionAccessFailure({ code: "access.invalid", cause }));
3763
4049
  if (cause instanceof ConnectionError) return lifecycleFailure(cause.code, cause.recoverable, cause.message, cause);
3764
4050
  if (cause instanceof DOMException && cause.name === "NotAllowedError") return lifecycleFailure("permission_denied", true, "Media permission was denied", cause);
3765
4051
  return lifecycleFailure(code, true, message, cause);
3766
4052
  }
4053
+ function sfuAccessRejected(cause) {
4054
+ return cause instanceof CloudflareSFUError && (cause.options.status === 401 || cause.options.status === 403);
4055
+ }
3767
4056
  function accessRejected(cause) {
3768
- return cause instanceof ConnectionError ? cause.code === "invalid_access" : typeof cause === "object" && cause !== null && "code" in cause && cause.code === "access.invalid";
4057
+ return cause instanceof ConnectionError ? cause.code === "invalid_access" : typeof cause === "object" && cause !== null && "code" in cause && (cause.code === "access.invalid" || cause.code === "participant.connection_superseded");
3769
4058
  }
3770
4059
  function boundedInteger(value, fallback, minimum, maximum) {
3771
4060
  const result = value ?? fallback;
@@ -3795,6 +4084,7 @@ function syncTerminalFailure(snapshot) {
3795
4084
  const reason = snapshot.connection.terminalReason;
3796
4085
  if (reason === "state_digest_recovery_failed") return lifecycleFailure("state_digest_recovery_failed", false, "The Episode state could not be recovered from an authoritative snapshot");
3797
4086
  if (reason === "episode_ended") return lifecycleFailure("episode_ended", false, "The Episode has ended");
4087
+ if (reason === "stale_participant_generation") return lifecycleFailure("invalid_access", false, "Call access was replaced. Another tab or device may be using it. Rejoin explicitly to request access again.");
3798
4088
  if (reason === "participant_inactive" || reason === "participant_removed" || reason === "stale_participant_generation" || reason === "access_rejected") return lifecycleFailure("invalid_access", false, "Participant access is no longer valid");
3799
4089
  if (reason === "entry_timeout") return lifecycleFailure("sync_start_failed", true, "Call entry was not confirmed in time");
3800
4090
  return lifecycleFailure("sync_start_failed", true, "The Sync transport needs recovery");
@@ -3809,6 +4099,22 @@ function syncStartupFailure(snapshot) {
3809
4099
  function syncAutoRecoverable(snapshot, failure2) {
3810
4100
  return failure2.recoverable && snapshot.connection.terminalReason !== "entry_timeout";
3811
4101
  }
4102
+ function statusIsEnded(status) {
4103
+ return status === "ending" || status === "ended";
4104
+ }
4105
+ function statusCredentialsRejected(error2) {
4106
+ return error2 instanceof EpisodeStatusError && (error2.status === 401 || error2.status === 403);
4107
+ }
4108
+ function readEpisodeStatus(read, grant, rejectedToken) {
4109
+ if (grant.media.token === rejectedToken) return Effect2.succeed({ status: null, cause: null });
4110
+ return Effect2.tryPromise({
4111
+ try: (signal) => read ? read(grant, signal) : Promise.reject(new Error("Episode status lookup unavailable")),
4112
+ catch: (cause) => cause
4113
+ }).pipe(Effect2.timeout(3e3), Effect2.match({ onSuccess: (status) => ({ status, cause: null }), onFailure: (cause) => ({ status: null, cause }) }));
4114
+ }
4115
+ function sameBindingCredential(current, original) {
4116
+ return current?.media.clientPayload.connectionId === original.media.clientPayload.connectionId ? current : original;
4117
+ }
3812
4118
 
3813
4119
  // src/space-client/errors.ts
3814
4120
  import { Data as Data3 } from "effect";
@@ -3909,7 +4215,7 @@ function connectionErrorCode(code, action) {
3909
4215
 
3910
4216
  // src/space-client/store.ts
3911
4217
  import { Context as Context4, Effect as Effect3, Layer as Layer4, SubscriptionRef as SubscriptionRef2 } from "effect";
3912
- var INTERRUPTED_CONNECTION_PHASES = /* @__PURE__ */ new Set(["unresponsive", "connecting", "recovering"]);
4218
+ var INTERRUPTED_CONNECTION_PHASES = /* @__PURE__ */ new Set(["unresponsive", "connecting", "recovering", "stopped"]);
3913
4219
  var empty = (value) => Object.freeze(value);
3914
4220
  var EMPTY = empty({
3915
4221
  connection: empty({ status: "idle", episode: null, lastError: null, episodeEnded: false, removedByHost: false }),
@@ -4572,11 +4878,79 @@ function chatError(message) {
4572
4878
  }
4573
4879
 
4574
4880
  // src/space-client/media-controller.ts
4575
- import { Clock as Clock3, Context as Context6, Deferred as Deferred2, Effect as Effect5, Exit as Exit2, Layer as Layer6, Semaphore } from "effect";
4881
+ import { Clock as Clock3, Context as Context6, Deferred as Deferred3, Effect as Effect5, Exit as Exit2, Layer as Layer6, Semaphore } from "effect";
4882
+
4883
+ // src/space-client/media-projection-monitor.ts
4884
+ var GRACE_MS = 1e4;
4885
+ function projectionMatches(observation) {
4886
+ return observation.enabled === observation.projectedEnabled && (!observation.enabled || observation.publicationId === observation.projectedPublicationId);
4887
+ }
4888
+ var MediaProjectionMonitor = class {
4889
+ #options;
4890
+ #actions = /* @__PURE__ */ new Map();
4891
+ constructor(options) {
4892
+ this.#options = options;
4893
+ }
4894
+ observe(source, observation) {
4895
+ let action = this.#actions.get(source);
4896
+ if (action?.revision !== observation.revision) {
4897
+ this.#clear(source);
4898
+ action = { revision: observation.revision, mismatch: null, reported: false, failed: false };
4899
+ this.#actions.set(source, action);
4900
+ }
4901
+ if (action.failed) return;
4902
+ if (projectionMatches(observation)) {
4903
+ if (action.mismatch) this.#options.cancel(action.mismatch.timer);
4904
+ action.mismatch = null;
4905
+ return;
4906
+ }
4907
+ if (action.mismatch) {
4908
+ action.mismatch.observation = observation;
4909
+ if (observation.enabled && this.#options.now() - action.mismatch.startedAt >= GRACE_MS) this.#expire(source, action);
4910
+ return;
4911
+ }
4912
+ const timer = this.#options.schedule(() => this.#expire(source, action), GRACE_MS);
4913
+ action.mismatch = { observation, startedAt: this.#options.now(), timer };
4914
+ }
4915
+ state(source) {
4916
+ const action = this.#actions.get(source);
4917
+ if (action?.failed) return "failed";
4918
+ return action?.mismatch?.observation.enabled ? "requesting" : null;
4919
+ }
4920
+ reset() {
4921
+ for (const source of this.#actions.keys()) this.#clear(source);
4922
+ }
4923
+ #clear(source) {
4924
+ const action = this.#actions.get(source);
4925
+ if (action?.mismatch) this.#options.cancel(action.mismatch.timer);
4926
+ this.#actions.delete(source);
4927
+ }
4928
+ #expire(source, action) {
4929
+ if (this.#actions.get(source) !== action || !action.mismatch) return;
4930
+ const { observation, startedAt } = action.mismatch;
4931
+ action.failed = observation.enabled;
4932
+ if (observation.enabled) this.#options.pause(source, observation.revision);
4933
+ if (!action.reported) {
4934
+ action.reported = true;
4935
+ this.#options.report({
4936
+ event: "media_projection_mismatch",
4937
+ source,
4938
+ publication_state: observation.enabled ? "on" : "off",
4939
+ projection_state: observation.projectedEnabled ? "on" : "off",
4940
+ duration_ms: this.#options.now() - startedAt,
4941
+ attempt: 0,
4942
+ outcome: observation.enabled ? "failed" : "started"
4943
+ });
4944
+ }
4945
+ this.#options.changed();
4946
+ }
4947
+ };
4948
+
4949
+ // src/space-client/media-controller.ts
4576
4950
  var MEDIA_SOURCES = ["microphone", "camera", "screen"];
4577
4951
  var MEDIA_READY_TIMEOUT_MS = 1e4;
4578
4952
  function sourceWorker(enabled) {
4579
- return { desired: { enabled, revision: 0, previousEnabled: enabled }, result: { revision: 0, status: "confirmed" }, sentRevision: 0, initialRevision: 0, localEnabled: enabled, pause: null, serverSequence: -1, forcedRevision: null };
4953
+ 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" };
4580
4954
  }
4581
4955
  var MediaControllerService = class extends Context6.Service()("@chalk/client/MediaController") {
4582
4956
  };
@@ -4619,6 +4993,8 @@ var MediaControllerRuntime = class {
4619
4993
  #tracks = /* @__PURE__ */ new Map();
4620
4994
  #sources = { microphone: sourceWorker(true), camera: sourceWorker(true), screen: sourceWorker(false) };
4621
4995
  #syncLive = false;
4996
+ #disposed = false;
4997
+ #recovering = null;
4622
4998
  #pendingSources = /* @__PURE__ */ new Map();
4623
4999
  #pendingCaptures = /* @__PURE__ */ new Set();
4624
5000
  #readinessChecks = /* @__PURE__ */ new Set();
@@ -4634,6 +5010,8 @@ var MediaControllerRuntime = class {
4634
5010
  #unsubscribeConnection = null;
4635
5011
  #unsubscribeScreenEnded = null;
4636
5012
  #unsubscribePorts = null;
5013
+ #microphoneWatch = null;
5014
+ #projection;
4637
5015
  constructor(connection, store, selection, gates, now, fork) {
4638
5016
  this.#connection = connection;
4639
5017
  this.#store = store;
@@ -4641,6 +5019,16 @@ var MediaControllerRuntime = class {
4641
5019
  this.#gates = gates;
4642
5020
  this.#now = now;
4643
5021
  this.#fork = fork;
5022
+ this.#projection = new MediaProjectionMonitor({
5023
+ now,
5024
+ schedule: connection.scheduleUnsafe,
5025
+ cancel: connection.cancelScheduleUnsafe,
5026
+ changed: () => this.#publish(),
5027
+ report: (event) => connection.recordTransportDiagnostic(event),
5028
+ pause: (source, revision) => {
5029
+ if (this.#ports) this.#pausePublication(this.#ports, source, revision, "projection_mismatch");
5030
+ }
5031
+ });
4644
5032
  this.#unsubscribeConnection = connection.subscribe(() => this.#handleConnectionChange());
4645
5033
  this.#unsubscribeScreenEnded = connection.subscribeScreenEnded(() => this.#handleScreenEnded());
4646
5034
  this.#unsubscribePorts = connection.subscribePorts((ports) => this.#bind(ports));
@@ -4688,7 +5076,7 @@ var MediaControllerRuntime = class {
4688
5076
  }).pipe(Effect5.mapError(normalizeMediaError));
4689
5077
  };
4690
5078
  initialIntent() {
4691
- return { microphone: this.#sources.microphone.desired.enabled, camera: this.#sources.camera.desired.enabled };
5079
+ return { microphone: this.#hasConsent("microphone"), camera: this.#hasConsent("camera") };
4692
5080
  }
4693
5081
  captureInitial(intent) {
4694
5082
  for (const source of ["microphone", "camera"]) this.#sources[source].initialRevision = this.#sources[source].desired.revision;
@@ -4744,7 +5132,9 @@ var MediaControllerRuntime = class {
4744
5132
  this.#fork(this.#refreshDevices().pipe(Effect5.ignore));
4745
5133
  }
4746
5134
  dispose() {
4747
- for (const check of this.#readinessChecks) check(normalizeMediaError(cancelledCaptureError()));
5135
+ this.#disposed = true;
5136
+ this.#projection.reset();
5137
+ for (const check of this.#readinessChecks) check(normalizeMediaError(this.#cancelledCaptureError()));
4748
5138
  this.#cancelCaptures();
4749
5139
  for (const unsubscribe of [this.#unsubscribeMedia, this.#unsubscribeRequests, this.#unsubscribePause, this.#unsubscribeSync]) unsubscribe?.();
4750
5140
  this.#unsubscribeConnection?.();
@@ -4759,9 +5149,6 @@ var MediaControllerRuntime = class {
4759
5149
  this.#unsubscribePorts = null;
4760
5150
  this.#ports = null;
4761
5151
  this.#resetObservations();
4762
- this.#screenAudioTrack?.stop();
4763
- this.#screenAudioTrack = null;
4764
- for (const track of this.#tracks.values()) track.stop();
4765
5152
  this.#clearTracks();
4766
5153
  this.#clearRequests();
4767
5154
  this.#publish();
@@ -4777,10 +5164,11 @@ var MediaControllerRuntime = class {
4777
5164
  const check = (failure2) => {
4778
5165
  if (failure2) return resume(Effect5.fail(failure2));
4779
5166
  if (this.#sources[source].desired.revision !== revision) return resume(Effect5.void);
4780
- if (ports && (this.#ports?.sync !== ports.sync || this.#ports.media !== ports.media)) return resume(Effect5.fail(normalizeMediaError(cancelledCaptureError())));
5167
+ if (ports && !this.#canUsePorts(ports)) return resume(Effect5.fail(normalizeMediaError(this.#cancelledCaptureError())));
4781
5168
  const state = this.#connection.getSnapshot().state;
4782
- if (state === "live" && this.#ports?.sync.getSnapshot().connection.phase === "live" && this.#store.getSnapshot().connection.status === "live") return resume(Effect5.void);
4783
- if (state !== "joining" && state !== "reconnecting" && state !== "live") resume(Effect5.fail(normalizeMediaError(new V1SyncError("Media enable requires a live call", "not_live"))));
5169
+ const phases = [state, this.#ports?.sync.getSnapshot().connection.phase, this.#store.getSnapshot().connection.status];
5170
+ if (phases.every((phase) => phase === "live")) return resume(Effect5.void);
5171
+ if (!["joining", "reconnecting", "live"].includes(state)) resume(Effect5.fail(normalizeMediaError(new V1SyncError("Media enable requires a live call", "not_live"))));
4784
5172
  };
4785
5173
  this.#readinessChecks.add(check);
4786
5174
  const unsubscribe = this.#connection.subscribe(() => check());
@@ -4810,12 +5198,22 @@ var MediaControllerRuntime = class {
4810
5198
  #pauseTracks(source) {
4811
5199
  const track = this.#track(source);
4812
5200
  if (track) track.enabled = false;
5201
+ for (const publication of this.#ports?.media.getSnapshot().localTracks ?? []) if (publication.source === source) publication.track.enabled = false;
4813
5202
  if (source === "screen" && this.#screenAudioTrack) this.#screenAudioTrack.enabled = false;
4814
5203
  }
5204
+ #retainPause(state, next) {
5205
+ const previous = state.pause;
5206
+ state.pause = previous ? Promise.allSettled([previous, next]).then((results) => {
5207
+ for (const result of results) if (result.status === "rejected") throw result.reason;
5208
+ }) : next;
5209
+ void state.pause.catch(() => void 0);
5210
+ }
4815
5211
  #recordIntent(source, enabled, options) {
4816
5212
  const state = this.#sources[source];
4817
- const desired = { enabled, revision: state.desired.revision + 1, options, previousEnabled: state.localEnabled };
5213
+ const controlRevision = Math.max(state.serverSequence, this.#ports?.sync.getSnapshot().control?.revision ?? -1);
5214
+ const desired = { enabled, revision: state.desired.revision + 1, controlRevision, options, previousEnabled: state.localEnabled };
4818
5215
  state.desired = desired;
5216
+ state.capturePause = "none";
4819
5217
  if (!enabled) {
4820
5218
  state.localEnabled = false;
4821
5219
  this.#pauseTracks(source);
@@ -4825,13 +5223,8 @@ var MediaControllerRuntime = class {
4825
5223
  }
4826
5224
  const ports = this.#ports;
4827
5225
  if (ports) {
4828
- ports.media.setLocalSourceIntent?.(source, false);
4829
- const previous = state.pause;
4830
- const next = this.#beginLocalPause(ports, source);
4831
- state.pause = previous ? Promise.allSettled([previous, next]).then((results) => {
4832
- for (const result of results) if (result.status === "rejected") throw result.reason;
4833
- }) : next;
4834
- void state.pause.catch(() => void 0);
5226
+ ports.media.setLocalSourceIntent?.(source, false, "user_pause");
5227
+ this.#retainPause(state, this.#beginLocalPause(ports, source, "user_pause"));
4835
5228
  }
4836
5229
  }
4837
5230
  for (const check of this.#readinessChecks) check();
@@ -4839,6 +5232,7 @@ var MediaControllerRuntime = class {
4839
5232
  }
4840
5233
  #act(source, enabled, options) {
4841
5234
  return Effect5.suspend(() => {
5235
+ if (this.#ports?.media.getSnapshot().connection.phase === "stopped") return Effect5.void;
4842
5236
  const desired = this.#recordIntent(source, enabled, options);
4843
5237
  const ready = enabled && source !== "screen" && this.#store.getSnapshot().connection.status !== "live" ? this.#waitForLive(source, desired.revision).pipe(
4844
5238
  Effect5.tapError(
@@ -4923,7 +5317,16 @@ var MediaControllerRuntime = class {
4923
5317
  const state = this.#sources[source];
4924
5318
  if (!intent.enabled && ports.sync.getSnapshot().connection.phase !== "live") return;
4925
5319
  const previous = intent.previousEnabled;
4926
- if (state.desired.revision === intent.revision) state.result = { revision: intent.revision, status: "failed", cause, cleanup: !intent.enabled || !previous };
5320
+ if (state.desired.revision !== intent.revision) return;
5321
+ state.result = { revision: intent.revision, status: "failed", cause, cleanup: !intent.enabled || !previous };
5322
+ if (state.result.cleanup) {
5323
+ if (intent.enabled && source !== "screen") this.#pausePublication(ports, source, intent.revision, "enable_failure");
5324
+ else {
5325
+ state.localEnabled = false;
5326
+ this.#pauseTracks(source);
5327
+ ports.media.setLocalSourceIntent?.(source, false, "enable_failure");
5328
+ }
5329
+ }
4927
5330
  });
4928
5331
  }
4929
5332
  #prepareDevice(ports, source, intent) {
@@ -5033,21 +5436,26 @@ var MediaControllerRuntime = class {
5033
5436
  })
5034
5437
  );
5035
5438
  }
5036
- #assertCurrentIntent(source, revision) {
5037
- if (this.#sources[source].desired.revision !== revision) throw cancelledCaptureError();
5439
+ #assertCurrentIntent(source, revision, enabling = true) {
5440
+ const state = this.#sources[source];
5441
+ if (state.desired.revision !== revision || enabling && state.forcedRevision === revision) throw this.#cancelledCaptureError();
5442
+ }
5443
+ #hasConsent(source) {
5444
+ const state = this.#sources[source];
5445
+ return state.desired.enabled && state.forcedRevision !== state.desired.revision;
5038
5446
  }
5039
5447
  #resetObservations() {
5448
+ if (this.#connection.getSnapshot().state !== "reconnecting") this.#recovering = null;
5040
5449
  for (const source of MEDIA_SOURCES) {
5041
5450
  const state = this.#sources[source];
5042
5451
  state.result = { revision: state.desired.revision, status: "confirmed" };
5043
5452
  state.pause = null;
5044
5453
  state.serverSequence = -1;
5045
- state.forcedRevision = null;
5046
5454
  }
5047
5455
  }
5048
5456
  #pauseInitialTracks() {
5049
5457
  for (const source of ["microphone", "camera"]) {
5050
- const enabled = this.#sources[source].desired.enabled;
5458
+ const enabled = this.#hasConsent(source);
5051
5459
  const track = this.#track(source);
5052
5460
  if (track && !enabled) track.enabled = false;
5053
5461
  this.#sources[source].localEnabled = !!track && enabled;
@@ -5058,15 +5466,14 @@ var MediaControllerRuntime = class {
5058
5466
  const revisions = { microphone: this.#sources.microphone.initialRevision, camera: this.#sources.camera.initialRevision };
5059
5467
  const tracks = stream.getTracks().filter((track) => {
5060
5468
  const source = track.kind === "audio" ? "microphone" : "camera";
5061
- const state = this.#sources[source];
5062
- track.enabled = state.desired.enabled;
5063
- if (!state.desired.enabled) {
5469
+ const enabled = this.#hasConsent(source);
5470
+ track.enabled = enabled;
5471
+ if (!enabled) {
5064
5472
  this.#deleteTrack(source);
5065
5473
  if (source === "microphone") track.stop();
5066
5474
  }
5067
- return state.desired.enabled;
5475
+ return enabled;
5068
5476
  });
5069
- for (const source of ["microphone", "camera"]) this.#sources[source].sentRevision = revisions[source];
5070
5477
  await media.start(streamFromTracks(tracks));
5071
5478
  for (const source of ["microphone", "camera"]) {
5072
5479
  const state = this.#sources[source];
@@ -5079,11 +5486,10 @@ var MediaControllerRuntime = class {
5079
5486
  const command = this.#mediaAction(
5080
5487
  enabled,
5081
5488
  foreign2(async () => {
5082
- this.#assertCurrentIntent(source, intent.revision);
5489
+ this.#assertCurrentIntent(source, intent.revision, enabled);
5083
5490
  this.#assertActivePorts(ports, source === "screen" ? "startScreenShare" : source === "camera" ? "setCameraEnabled" : "setMicrophoneEnabled");
5084
- const state = this.#sources[source];
5085
- state.sentRevision = intent.revision;
5086
- await (source === "microphone" ? ports.sync.setMicrophoneEnabled(enabled) : source === "camera" ? ports.sync.setCameraEnabled(enabled) : ports.sync.setScreenShareEnabled(enabled));
5491
+ const send = source === "microphone" ? ports.sync.setMicrophoneEnabled : source === "camera" ? ports.sync.setCameraEnabled : ports.sync.setScreenShareEnabled;
5492
+ await send.call(ports.sync, enabled);
5087
5493
  }).pipe(
5088
5494
  Effect5.andThen(enabled && source !== "screen" ? this.#waitForLive(source, intent.revision, ports) : Effect5.void),
5089
5495
  Effect5.tap(() => Effect5.try({ try: () => this.#assertActivePorts(ports, source === "screen" ? "startScreenShare" : source === "camera" ? "setCameraEnabled" : "setMicrophoneEnabled"), catch: (cause) => cause }))
@@ -5093,10 +5499,19 @@ var MediaControllerRuntime = class {
5093
5499
  }
5094
5500
  #reconcileIntents() {
5095
5501
  const live = this.#connection.getSnapshot().state === "live" && this.#ports?.sync.getSnapshot().connection.phase === "live";
5502
+ if (this.#syncLive && !live) this.#rememberRecoveryIntents();
5096
5503
  const resumed = live && !this.#syncLive;
5097
5504
  this.#syncLive = live;
5098
5505
  if (!resumed) return;
5099
- for (const source of MEDIA_SOURCES) this.#fork(this.#serialize(source, this.#work(source)).pipe(Effect5.ignore));
5506
+ if (this.#ports) this.#pauseRecoveredIntents(this.#ports, this.#ports.sync.getSnapshot());
5507
+ this.#resumePendingActions();
5508
+ }
5509
+ #resumePendingActions() {
5510
+ for (const source of MEDIA_SOURCES) {
5511
+ const state = this.#sources[source];
5512
+ if (source !== "screen" && (state.desired.enabled || state.result.revision === state.desired.revision)) continue;
5513
+ this.#fork(this.#serialize(source, this.#work(source)).pipe(Effect5.ignore));
5514
+ }
5100
5515
  }
5101
5516
  #clearScreenAudio(media) {
5102
5517
  return foreign2(() => media.clearPreparedLocalTrack("screen_audio")).pipe(
@@ -5108,8 +5523,9 @@ var MediaControllerRuntime = class {
5108
5523
  )
5109
5524
  );
5110
5525
  }
5111
- #beginLocalPause(ports, source) {
5112
- const pause = Effect5.runPromise(this.#localTarget(ports, source, false));
5526
+ #beginLocalPause(ports, source, cause) {
5527
+ this.#sources[source].pausedPublicationId = ports.media.getSnapshot().localTracks.find((item) => item.source === source)?.publicationId ?? null;
5528
+ const pause = ports.media.closeForcedLocalPublication ? Promise.resolve().then(() => ports.media.closeForcedLocalPublication(source, cause)) : Effect5.runPromise(this.#localTarget(ports, source, false));
5113
5529
  void pause.catch(() => void 0);
5114
5530
  return pause;
5115
5531
  }
@@ -5178,18 +5594,53 @@ var MediaControllerRuntime = class {
5178
5594
  #setTrack(source, track) {
5179
5595
  if (source !== "camera") {
5180
5596
  this.#tracks.set(source, track);
5597
+ if (source === "microphone") this.#watchMicrophone(track);
5181
5598
  return;
5182
5599
  }
5183
5600
  const state = this.#camera;
5184
- if (!("owner" in state)) throw cancelledCaptureError();
5185
- if (state.status === "live" && state.track !== track) throw cancelledCaptureError();
5186
- const onEnded = () => {
5187
- if (this.#camera.status === "live" && this.#camera.track === track) this.#transitionCamera({ status: "ended" });
5188
- };
5601
+ if (!("owner" in state)) throw this.#cancelledCaptureError();
5602
+ if (state.status === "live" && state.track !== track) throw this.#cancelledCaptureError();
5603
+ const onEnded = () => this.#handleCameraEnded(track);
5189
5604
  this.#transitionCamera({ status: "live", owner: state.owner, track, publication: this.#ports?.media ?? "initial", onEnded });
5190
5605
  }
5606
+ #handleCameraEnded(track) {
5607
+ const state = this.#camera;
5608
+ if (state.status !== "live" || state.track !== track) return;
5609
+ if (this.#ports && state.publication === this.#ports.media) this.#pausePublication(this.#ports, "camera", this.#sources.camera.desired.revision, "capture_cleanup");
5610
+ this.#transitionCamera({ status: "ended" });
5611
+ }
5612
+ #watchMicrophone(track) {
5613
+ if (this.#microphoneWatch?.track === track) return;
5614
+ this.#microphoneWatch?.unsubscribe();
5615
+ this.#microphoneWatch = null;
5616
+ if (!track) return;
5617
+ const changed = () => {
5618
+ if (this.#microphoneWatch?.track !== track) return;
5619
+ if (track.muted || track.readyState === "ended") {
5620
+ track.enabled = false;
5621
+ const state = this.#sources.microphone;
5622
+ if (this.#ports) this.#pausePublication(this.#ports, "microphone", state.desired.revision, "capture_cleanup");
5623
+ else {
5624
+ state.forcedRevision = state.desired.revision;
5625
+ state.localEnabled = false;
5626
+ }
5627
+ }
5628
+ this.#publish();
5629
+ };
5630
+ for (const event of ["mute", "unmute", "ended"]) track.addEventListener(event, changed);
5631
+ this.#microphoneWatch = {
5632
+ track,
5633
+ unsubscribe: () => {
5634
+ for (const event of ["mute", "unmute", "ended"]) track.removeEventListener(event, changed);
5635
+ }
5636
+ };
5637
+ if (track.muted || track.readyState === "ended") changed();
5638
+ }
5191
5639
  #deleteTrack(source) {
5192
5640
  if (source !== "camera") {
5641
+ if (source === "microphone") {
5642
+ this.#watchMicrophone(null);
5643
+ }
5193
5644
  this.#tracks.delete(source);
5194
5645
  return;
5195
5646
  }
@@ -5200,6 +5651,7 @@ var MediaControllerRuntime = class {
5200
5651
  } else this.#transitionCamera({ status: "ended" });
5201
5652
  }
5202
5653
  #clearTracks() {
5654
+ this.#watchMicrophone(null);
5203
5655
  this.#tracks.clear();
5204
5656
  this.#transitionCamera({ status: "idle" });
5205
5657
  }
@@ -5242,11 +5694,11 @@ var MediaControllerRuntime = class {
5242
5694
  #beginCameraCapture(acquire) {
5243
5695
  const abort = new AbortController();
5244
5696
  const owner = {
5245
- result: Deferred2.makeUnsafe(),
5697
+ result: Deferred3.makeUnsafe(),
5246
5698
  references: 0,
5247
5699
  cancel: () => {
5248
5700
  abort.abort();
5249
- Deferred2.doneUnsafe(owner.result, Effect5.fail(cancelledCaptureError()));
5701
+ Deferred3.doneUnsafe(owner.result, Effect5.fail(this.#cancelledCaptureError()));
5250
5702
  }
5251
5703
  };
5252
5704
  this.#transitionCamera({ status: "capturing", owner });
@@ -5258,21 +5710,19 @@ var MediaControllerRuntime = class {
5258
5710
  const state = this.#camera;
5259
5711
  if (abort.signal.aborted || !("owner" in state) || state.owner !== owner) {
5260
5712
  track.stop();
5261
- throw cancelledCaptureError();
5713
+ throw this.#cancelledCaptureError();
5262
5714
  }
5263
- const onEnded = () => {
5264
- if (this.#camera.status === "live" && this.#camera.owner === owner) this.#transitionCamera({ status: "ended" });
5265
- };
5715
+ const onEnded = () => this.#handleCameraEnded(track);
5266
5716
  this.#transitionCamera({ status: "live", owner, track, publication: state.status === "live" ? state.publication : null, onEnded });
5267
5717
  if (track.readyState !== "live") {
5268
5718
  this.#transitionCamera({ status: "ended" });
5269
- throw cancelledCaptureError();
5719
+ throw this.#cancelledCaptureError();
5270
5720
  }
5271
5721
  },
5272
5722
  catch: (cause) => cause
5273
5723
  })
5274
5724
  ),
5275
- Effect5.onExit((exit) => Deferred2.done(owner.result, exit)),
5725
+ Effect5.onExit((exit) => Deferred3.done(owner.result, exit)),
5276
5726
  Effect5.ignore
5277
5727
  )
5278
5728
  );
@@ -5288,8 +5738,8 @@ var MediaControllerRuntime = class {
5288
5738
  const current = this.#camera;
5289
5739
  if (owner.references === 0 && "owner" in current && current.owner === owner && (current.status !== "live" || current.publication === null)) this.#transitionCamera({ status: "ended" });
5290
5740
  };
5291
- return Deferred2.await(owner.result).pipe(
5292
- Effect5.flatMap((track) => track.readyState === "live" ? Effect5.succeed({ track, release }) : Effect5.fail(cancelledCaptureError())),
5741
+ return Deferred3.await(owner.result).pipe(
5742
+ Effect5.flatMap((track) => track.readyState === "live" ? Effect5.succeed({ track, release }) : Effect5.fail(this.#cancelledCaptureError())),
5293
5743
  Effect5.onExit(
5294
5744
  (exit) => Effect5.sync(() => {
5295
5745
  if (Exit2.isFailure(exit)) release();
@@ -5318,26 +5768,29 @@ var MediaControllerRuntime = class {
5318
5768
  return Effect5.suspend(() => {
5319
5769
  let cancelled = false;
5320
5770
  let captured = null;
5771
+ const cancellation = Deferred3.makeUnsafe();
5321
5772
  const cancel = () => {
5322
5773
  cancelled = true;
5323
5774
  stopStream(captured);
5775
+ Deferred3.doneUnsafe(cancellation, Effect5.fail(this.#cancelledCaptureError()));
5324
5776
  };
5325
5777
  this.#pendingCaptures.add(cancel);
5326
5778
  signal?.addEventListener("abort", cancel, { once: true });
5327
5779
  if (signal?.aborted) cancel();
5328
5780
  return foreign2(async () => {
5329
- if (cancelled) throw cancelledCaptureError();
5781
+ if (cancelled) throw this.#cancelledCaptureError();
5330
5782
  const stream = await operation();
5331
5783
  captured = stream;
5332
- if (cancelled) {
5784
+ if (cancelled || this.#ports?.media.getSnapshot().connection.phase === "stopped") {
5333
5785
  stopStream(stream);
5334
- throw cancelledCaptureError();
5786
+ throw this.#cancelledCaptureError();
5335
5787
  }
5336
5788
  return stream;
5337
5789
  }).pipe(
5338
5790
  Effect5.mapError(onCaptureError),
5339
5791
  Effect5.flatMap(use),
5340
- Effect5.tap(() => cancelled ? Effect5.fail(cancelledCaptureError()) : Effect5.void),
5792
+ Effect5.raceFirst(Deferred3.await(cancellation)),
5793
+ Effect5.tap(() => cancelled ? Effect5.fail(this.#cancelledCaptureError()) : Effect5.void),
5341
5794
  Effect5.onExit(
5342
5795
  (exit) => Effect5.sync(() => {
5343
5796
  this.#pendingCaptures.delete(cancel);
@@ -5352,6 +5805,9 @@ var MediaControllerRuntime = class {
5352
5805
  this.#transitionCamera({ status: "ended" });
5353
5806
  for (const cancel of this.#pendingCaptures) cancel();
5354
5807
  this.#pendingCaptures.clear();
5808
+ this.#screenAudioTrack?.stop();
5809
+ this.#screenAudioTrack = null;
5810
+ for (const track of this.#tracks.values()) track.stop();
5355
5811
  }
5356
5812
  #captureSource(source, signal) {
5357
5813
  return this.#captureStream(
@@ -5408,42 +5864,71 @@ var MediaControllerRuntime = class {
5408
5864
  const failure2 = isPermissionDenied(input.cause) || captureWasUnavailable ? captureError(input.cause, input.action, input.permissionMessage) : input.cause;
5409
5865
  return rollback.pipe(Effect5.andThen(Effect5.sync(() => this.#publish())), Effect5.andThen(Effect5.fail(failure2)));
5410
5866
  }
5867
+ #cancelledCaptureError() {
5868
+ 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" });
5869
+ }
5870
+ #canUsePorts(ports) {
5871
+ return this.#ports?.sync === ports.sync && this.#ports.media === ports.media && ports.media.getSnapshot().connection.phase !== "stopped";
5872
+ }
5411
5873
  #assertActivePorts(ports, action) {
5412
- if (this.#connection.getSnapshot().state === "live" && this.#ports?.sync === ports.sync && this.#ports.media === ports.media) return;
5413
- throw new ConnectionError({ code: "invalid_state", action, recoverable: false, message: `${action} belongs to an inactive connection` });
5874
+ if (this.#connection.getSnapshot().state === "live" && this.#canUsePorts(ports)) return;
5875
+ 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` });
5414
5876
  }
5415
- #pausePublication(ports, source, revision) {
5877
+ #pausePublication(ports, source, revision, cause) {
5416
5878
  const state = this.#sources[source];
5417
- if (state.desired.revision !== revision || ports.sync.getSnapshot().control?.status === "ended") return;
5879
+ if (state.desired.revision !== revision || state.forcedRevision === revision || ports.sync.getSnapshot().control?.status === "ended") return;
5418
5880
  state.forcedRevision = revision;
5419
5881
  state.localEnabled = false;
5420
5882
  this.#pauseTracks(source);
5421
- ports.media.setLocalSourceIntent?.(source, false);
5422
- if (ports.media.closeForcedLocalPublication) this.#fork(foreign2(() => ports.media.closeForcedLocalPublication(source)).pipe(Effect5.ignore));
5883
+ if (cause === "capture_cleanup" && source !== "screen" && ports.media.pauseLocalCapture) {
5884
+ state.capturePause = "pending";
5885
+ this.#retainPause(state, ports.media.pauseLocalCapture(source));
5886
+ this.#notifyCapturePauses(ports);
5887
+ } else {
5888
+ ports.media.setLocalSourceIntent?.(source, false, cause);
5889
+ this.#retainPause(state, this.#beginLocalPause(ports, source, cause));
5890
+ }
5423
5891
  this.#publish();
5424
5892
  }
5893
+ #notifyCapturePauses(ports) {
5894
+ for (const source of ["microphone", "camera"]) {
5895
+ const state = this.#sources[source];
5896
+ if (state.capturePause === "pending") this.#retainPause(state, this.#announceCapturePause(ports, source));
5897
+ }
5898
+ }
5899
+ async #announceCapturePause(ports, source) {
5900
+ const snapshot = ports.sync.getSnapshot();
5901
+ if (snapshot.connection.phase !== "live") return;
5902
+ const owned = ports.media.getSnapshot().localTracks.find((item) => item.source === source);
5903
+ const projected = snapshot.media?.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
5904
+ if (!owned?.publicationId || !projected?.enabled || projected.publicationId !== owned.publicationId) return;
5905
+ this.#sources[source].capturePause = "announced";
5906
+ const send = source === "microphone" ? ports.sync.setMicrophoneEnabled : ports.sync.setCameraEnabled;
5907
+ await send.call(ports.sync, false);
5908
+ }
5425
5909
  #bind(ports) {
5910
+ if (ports?.media.getSnapshot().connection.phase === "stopped") this.#cancelCaptures();
5426
5911
  if (ports && this.#ports?.sync === ports.sync && this.#ports.media === ports.media) return;
5912
+ this.#rememberRecoveryIntents();
5427
5913
  for (const unsubscribe of [this.#unsubscribeMedia, this.#unsubscribeRequests, this.#unsubscribePause, this.#unsubscribeSync]) unsubscribe?.();
5428
5914
  this.#unsubscribeMedia = null;
5429
5915
  this.#unsubscribeRequests = null;
5430
5916
  this.#unsubscribePause = null;
5431
5917
  this.#unsubscribeSync = null;
5432
5918
  this.#ports = ports;
5919
+ this.#projection.reset();
5433
5920
  this.#syncLive = false;
5434
5921
  this.#bindCamera(ports);
5435
5922
  if (!ports) {
5436
5923
  this.#resetObservations();
5437
5924
  this.#cancelCaptures();
5438
- this.#screenAudioTrack?.stop();
5439
- this.#screenAudioTrack = null;
5440
- for (const track of this.#tracks.values()) track.stop();
5441
5925
  this.#clearTracks();
5442
5926
  this.#clearRequests();
5443
5927
  this.#publish();
5444
5928
  return;
5445
5929
  }
5446
5930
  this.#unsubscribeMedia = ports.media.subscribe(() => {
5931
+ if (ports.media.getSnapshot().connection.phase === "stopped") this.#cancelCaptures();
5447
5932
  this.#applyRemoteMediaState(ports, ports.sync.getSnapshot());
5448
5933
  this.#publish();
5449
5934
  });
@@ -5458,41 +5943,29 @@ var MediaControllerRuntime = class {
5458
5943
  const state = this.#sources[pause.source];
5459
5944
  if (pause.revision <= state.serverSequence) return;
5460
5945
  state.serverSequence = pause.revision;
5461
- this.#pausePublication(ports, pause.source, state.sentRevision);
5946
+ if (pause.revision > state.desired.controlRevision) this.#pausePublication(ports, pause.source, state.desired.revision, "moderation_pause");
5462
5947
  }) ?? null;
5463
5948
  }
5464
- #rememberPublications(snapshot) {
5465
- const publications = /* @__PURE__ */ new Map();
5466
- const own = (snapshot.media?.items ?? []).filter((item) => item.participantId === snapshot.participantId && item.enabled && item.publicationId !== null);
5467
- for (const item of own) {
5468
- if (item.source === "screen_audio" || item.publicationId === null) continue;
5469
- const state = this.#sources[item.source];
5470
- if (state.localEnabled && state.sentRevision === state.desired.revision) publications.set(item.source, { revision: state.sentRevision, publicationId: item.publicationId });
5471
- }
5472
- return publications;
5949
+ #rememberRecoveryIntents() {
5950
+ for (const state of Object.values(this.#sources)) if (state.capturePause === "announced") state.capturePause = "pending";
5951
+ if (!this.#ports) return;
5952
+ this.#recovering ??= new Map(MEDIA_SOURCES.filter((source) => this.#sources[source].desired.enabled).map((source) => [source, this.#sources[source].desired.revision]));
5473
5953
  }
5474
- #pauseLostPublications(ports, snapshot, publications) {
5475
- for (const [source, previous] of publications) {
5954
+ #pauseRecoveredIntents(ports, snapshot) {
5955
+ const recovering = this.#recovering;
5956
+ this.#recovering = null;
5957
+ for (const [source, revision] of recovering ?? []) {
5476
5958
  const current = snapshot.media?.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
5477
- if (!current || !current.enabled && (current.publicationId === previous.publicationId || current.publicationId === null)) this.#pausePublication(ports, source, previous.revision);
5959
+ if (!current?.enabled) this.#pausePublication(ports, source, revision, "sync_recovery_loss");
5478
5960
  }
5479
5961
  }
5480
5962
  #subscribeSync(ports) {
5481
- let recovering = /* @__PURE__ */ new Map();
5482
- let lastLive = ports.sync.getSnapshot();
5483
- let wasLive = lastLive.connection.phase === "live";
5484
5963
  return ports.sync.subscribe((snapshot) => {
5485
- const live = snapshot.connection.phase === "live";
5486
- if (wasLive && !live) recovering = this.#rememberPublications(lastLive);
5487
- if (live && !wasLive) {
5488
- this.#pauseLostPublications(ports, snapshot, recovering);
5489
- recovering.clear();
5490
- }
5491
- if (live) lastLive = snapshot;
5492
- wasLive = live;
5493
5964
  this.#removeRequestsFromMissingParticipants(snapshot);
5494
5965
  this.#applyRemoteMediaState(ports, snapshot);
5495
5966
  this.#reconcileIntents();
5967
+ this.#notifyCapturePauses(ports);
5968
+ this.#publish();
5496
5969
  });
5497
5970
  }
5498
5971
  #bindCamera(ports) {
@@ -5566,14 +6039,64 @@ var MediaControllerRuntime = class {
5566
6039
  if (!track || this.#track("screen") !== track) return;
5567
6040
  this.#fork(this.#stopScreen().pipe(Effect5.ignore));
5568
6041
  }
6042
+ #observeProjections() {
6043
+ const ports = this.#ports;
6044
+ this.#watchMicrophone(ports?.media.getSnapshot().localTracks.find((item) => item.source === "microphone")?.track ?? this.#track("microphone") ?? null);
6045
+ if (!ports) {
6046
+ this.#projection.reset();
6047
+ return;
6048
+ }
6049
+ this.#enforcePause(ports, "microphone");
6050
+ this.#enforcePause(ports, "camera");
6051
+ if (!this.#syncLive) {
6052
+ this.#projection.reset();
6053
+ return;
6054
+ }
6055
+ this.#observeProjection(ports, "microphone");
6056
+ this.#observeProjection(ports, "camera");
6057
+ }
6058
+ #enforcePause(ports, source) {
6059
+ const publication = ports.media.getSnapshot().localTracks.find((item) => item.source === source);
6060
+ const state = this.#sources[source];
6061
+ if (!this.#hasConsent(source) && publication?.enabled && state.pausedPublicationId !== publication.publicationId) {
6062
+ this.#pauseTracks(source);
6063
+ ports.media.setLocalSourceIntent?.(source, false, "consent_revoked");
6064
+ this.#retainPause(state, this.#beginLocalPause(ports, source, "consent_revoked"));
6065
+ }
6066
+ }
6067
+ #observeProjection(ports, source) {
6068
+ const sync = ports.sync.getSnapshot();
6069
+ const state = this.#sources[source];
6070
+ const publication = ports.media.getSnapshot().localTracks.find((item) => item.source === source);
6071
+ const projection = sync.media?.items.find((item) => item.participantId === sync.participantId && item.source === source) ?? { enabled: false, publicationId: null };
6072
+ this.#projection.observe(source, {
6073
+ revision: state.desired.revision,
6074
+ publicationId: publication?.publicationId ?? null,
6075
+ enabled: livePublication(publication),
6076
+ projectedEnabled: projection.enabled,
6077
+ projectedPublicationId: projection.publicationId
6078
+ });
6079
+ }
6080
+ #confirmedLocal(source) {
6081
+ const snapshot = this.#ports?.media.getSnapshot();
6082
+ if (snapshot?.connection.phase === "stopped") return { source, state: "unavailable", track: null };
6083
+ const publication = snapshot?.localTracks.find((item) => item.source === source);
6084
+ const sync = this.#ports?.sync.getSnapshot();
6085
+ const projected = sync?.media?.items.some((item) => item.participantId === sync.participantId && item.source === source && item.enabled && item.publicationId === publication?.publicationId) ?? false;
6086
+ const confirmed = this.#syncLive && livePublication(publication) && projected;
6087
+ const pending = this.#pendingSources.has(source) || this.#connection.getSnapshot().state === "joining";
6088
+ const track = publication?.track ?? this.#track(source) ?? null;
6089
+ return { source, track, state: confirmedLocalMediaState(track, this.#hasConsent(source), pending, confirmed, this.#syncLive, this.#projection.state(source)) };
6090
+ }
5569
6091
  #publish() {
5570
6092
  const current = this.#store.getSnapshot();
5571
6093
  const snapshot = this.#ports?.media.getSnapshot();
5572
6094
  const state = this.#connection.getSnapshot().state;
6095
+ this.#observeProjections();
5573
6096
  const local = Object.freeze({
5574
- microphone: localMedia("microphone", this.#localTracks(), snapshot, this.#sources.microphone.localEnabled, state, this.#pendingSources.has("microphone") && this.#sources.microphone.desired.enabled),
5575
- camera: localMedia("camera", this.#localTracks(), snapshot, this.#sources.camera.localEnabled, state, this.#pendingSources.has("camera") && this.#sources.camera.desired.enabled),
5576
- screen: localMedia("screen", this.#localTracks(), snapshot, false, state, this.#pendingSources.has("screen") && this.#sources.screen.desired.enabled)
6097
+ microphone: this.#confirmedLocal("microphone"),
6098
+ camera: this.#confirmedLocal("camera"),
6099
+ screen: screenLocalMedia(this.#track("screen") ?? null, snapshot, state, this.#pendingSources.has("screen") && this.#sources.screen.desired.enabled)
5577
6100
  });
5578
6101
  const remote = Object.freeze((snapshot?.remoteTracks ?? []).map((track) => Object.freeze({ participantId: track.participantId, source: track.source, publicationId: track.publicationId, track: track.track })));
5579
6102
  const media = Object.freeze({
@@ -5590,65 +6113,52 @@ var MediaControllerRuntime = class {
5590
6113
  if (!sameMediaSlice(current.media, media)) this.#store.updateMedia(media);
5591
6114
  }
5592
6115
  };
5593
- function cancelledCaptureError() {
5594
- return new ConnectionError({ code: "invalid_state", action: null, recoverable: false, message: "Media capture was cancelled before its result could be used" });
6116
+ function liveTrack(track) {
6117
+ return track.enabled && track.readyState === "live" && !track.muted;
6118
+ }
6119
+ function livePublication(publication) {
6120
+ return !!publication?.enabled && liveTrack(publication.track);
6121
+ }
6122
+ function confirmedLocalMediaState(track, desired, pending, confirmed, live, convergence) {
6123
+ if (!desired) return convergence === "failed" ? "failed" : "disabled";
6124
+ if (convergence) return convergence;
6125
+ if (!live && track && liveTrack(track)) return "requesting";
6126
+ return confirmed ? "enabled" : pending ? "requesting" : "disabled";
5595
6127
  }
5596
6128
  function foreign2(operation) {
5597
6129
  return Effect5.tryPromise({ try: operation, catch: (cause) => cause });
5598
6130
  }
5599
6131
  function selectInitialTracks(stream, intent) {
5600
6132
  const tracks = stream.getTracks();
5601
- const microphone = initialTrackFor(tracks, intent.microphone, "audio");
5602
- const camera = initialTrackFor(tracks, intent.camera, "video");
5603
- const selected = new Set(initialTracks(microphone, camera));
5604
- for (const track of tracks) if (!selected.has(track)) track.stop();
5605
- if (missingInitialTrack(intent, microphone, camera)) {
5606
- for (const track of selected) track.stop();
6133
+ const selected = /* @__PURE__ */ new Map();
6134
+ for (const [source, kind] of [
6135
+ ["microphone", "audio"],
6136
+ ["camera", "video"]
6137
+ ]) {
6138
+ if (!intent[source]) continue;
6139
+ const track = tracks.find((track2) => track2.kind === kind);
6140
+ if (track) selected.set(source, track);
6141
+ }
6142
+ const retained = new Set(selected.values());
6143
+ for (const track of tracks) if (!retained.has(track)) track.stop();
6144
+ if (selected.size !== Number(intent.microphone) + Number(intent.camera)) {
6145
+ for (const track of retained) track.stop();
5607
6146
  throw new TypeError("Media capture did not return every requested track");
5608
6147
  }
5609
- return new Map(initialTrackEntries(microphone, camera));
5610
- }
5611
- function initialTrackFor(tracks, enabled, kind) {
5612
- if (!enabled) return void 0;
5613
- return tracks.find((track) => track.kind === kind);
5614
- }
5615
- function initialTracks(microphone, camera) {
5616
- return [microphone, camera].filter((track) => track !== void 0);
5617
- }
5618
- function missingInitialTrack(intent, microphone, camera) {
5619
- return intent.microphone && !microphone || intent.camera && !camera;
5620
- }
5621
- function initialTrackEntries(microphone, camera) {
5622
- const entries = [];
5623
- if (microphone) entries.push(["microphone", microphone]);
5624
- if (camera) entries.push(["camera", camera]);
5625
- return entries;
5626
- }
5627
- function localMedia(source, tracks, snapshot, intended, connectionState, pending) {
5628
- const track = tracks.get(source) ?? null;
5629
- const publication = snapshot?.localTracks.find((candidate) => candidate.source === source);
5630
- if (pending) return Object.freeze({ source, state: "requesting", track });
5631
- if (locallyMuted(intended, track, publication)) return Object.freeze({ source, state: "disabled", track });
5632
- if (publication?.enabled) return Object.freeze({ source, state: "enabled", track });
5633
- if (publication && connectionState === "live") return Object.freeze({ source, state: "disabled", track });
5634
- return Object.freeze({ source, state: localMediaState(source, track, intended, connectionState), track });
6148
+ return selected;
5635
6149
  }
5636
- function locallyMuted(intended, track, publication) {
5637
- return !intended && track?.enabled === false && publication?.enabled === false;
6150
+ function screenLocalMedia(track, snapshot, connectionState, pending) {
6151
+ if (snapshot?.connection.phase === "stopped") return Object.freeze({ source: "screen", state: "unavailable", track: null });
6152
+ const publication = snapshot?.localTracks.find((candidate) => candidate.source === "screen");
6153
+ return Object.freeze({ source: "screen", state: pending ? "requesting" : screenLocalMediaState(track, publication?.enabled, connectionState), track });
5638
6154
  }
5639
- function localMediaState(source, track, intended, connectionState) {
5640
- if (!mediaIsDesired(source, track, intended)) return inactiveLocalMediaState(connectionState, track);
5641
- if (connectionState === "joining" || track) return "requesting";
5642
- return activeLocalMediaState(connectionState);
5643
- }
5644
- function mediaIsDesired(source, track, intended) {
5645
- return source === "screen" ? track !== null : intended;
5646
- }
5647
- function inactiveLocalMediaState(connectionState, track) {
5648
- return connectionState === "live" || connectionState === "reconnecting" || track ? "disabled" : "unavailable";
5649
- }
5650
- function activeLocalMediaState(connectionState) {
5651
- return connectionState === "failed" || connectionState === "live" || connectionState === "reconnecting" ? "failed" : "unavailable";
6155
+ function screenLocalMediaState(track, enabled, connectionState) {
6156
+ if (enabled === false) {
6157
+ if (track?.enabled === false || connectionState === "live") return "disabled";
6158
+ }
6159
+ if (enabled) return "enabled";
6160
+ if (track) return "requesting";
6161
+ return connectionState === "live" || connectionState === "reconnecting" ? "disabled" : "unavailable";
5652
6162
  }
5653
6163
  function sameMediaSlice(left, right) {
5654
6164
  if (left.selection !== right.selection || !sameDevices(left.devices, right.devices)) return false;
@@ -6655,7 +7165,7 @@ var SpaceClientCore = class {
6655
7165
  return this.#connection.leave().pipe(Effect12.mapError(normalizeClientError));
6656
7166
  }
6657
7167
  endEpisode() {
6658
- return this.#connection.runCommand(({ sync }) => foreign6(() => sync.endEpisode()).pipe(Effect12.tap(() => this.#connection.confirmEpisodeEnded))).pipe(Effect12.tap(() => this.#connection.leave())).pipe(Effect12.asVoid, Effect12.mapError(normalizeClientError));
7168
+ return this.#connection.runCommand(({ sync }) => this.#connection.runEpisodeEnd(foreign6(() => sync.endEpisode()))).pipe(Effect12.tap(() => this.#connection.leave())).pipe(Effect12.asVoid, Effect12.mapError(normalizeClientError));
6659
7169
  }
6660
7170
  extendEpisode(minutes) {
6661
7171
  return this.#connection.runCommand(({ sync }) => foreign6(() => sync.extendEpisode(minutes))).pipe(Effect12.asVoid, Effect12.mapError(normalizeClientError));
@@ -6711,7 +7221,7 @@ var SpaceClientCore = class {
6711
7221
  }
6712
7222
  }
6713
7223
  #requireActive() {
6714
- if (this.#disposed) throw new SpaceClientError({ code: "connection.invalid_state", recoverable: false, message: "The SpaceClient has been disposed" });
7224
+ if (this.#disposed) throw new SpaceClientError({ code: "connection.invalid_state", recoverable: false, message: "The SpaceClient has been disposed. Rejoin the call." });
6715
7225
  }
6716
7226
  };
6717
7227
  function liveClock(dependencies) {
@@ -6731,7 +7241,14 @@ function foreign6(operation) {
6731
7241
  }
6732
7242
  async function fetchAccessGrant2(input) {
6733
7243
  if (input.platform.connectionAccess) return input.platform.connectionAccess(input.request);
6734
- return input.options.getAccess({ space: input.options.space.trim(), reason: accessReason(input.request?.reason), replaceMediaConnection: input.request?.replaceMediaConnection ?? false, signal: input.request?.signal });
7244
+ return input.options.getAccess({
7245
+ space: input.options.space.trim(),
7246
+ reason: accessReason(input.request?.reason),
7247
+ replaceMediaConnection: input.request?.replaceMediaConnection ?? false,
7248
+ expectedMediaConnectionId: input.request?.expectedMediaConnectionId,
7249
+ currentMediaToken: input.request?.replaceMediaConnection ? input.request.currentMediaToken : void 0,
7250
+ signal: input.request?.signal
7251
+ });
6735
7252
  }
6736
7253
  function accessReason(reason) {
6737
7254
  if (reason === void 0 || reason === "join") return "join";