@q9labsai/chalk-client 5.2.4 → 5.2.5

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.
@@ -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-FD25AMLG.js";
6
10
  import {
7
11
  AccessGrantError,
8
12
  requireParsedAccessGrant
9
- } from "./chunk-AZONR3Q3.js";
13
+ } from "./chunk-UMAE5GCV.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);
@@ -2665,7 +2771,7 @@ function scheduleForegroundDeadline(clock, milliseconds2, expired) {
2665
2771
  }
2666
2772
 
2667
2773
  // 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";
2774
+ 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
2775
 
2670
2776
  // src/connection/diagnostics.ts
2671
2777
  var ConnectionDiagnostics = class {
@@ -2690,11 +2796,17 @@ var ConnectionDiagnostics = class {
2690
2796
  }
2691
2797
  recordRecoveryExhausted(input) {
2692
2798
  const { state, epoch, code, attempts, transport } = input;
2799
+ const media = input.media ?? (code === "media_recovery_exhausted" ? {
2800
+ media_error_kind: "timeout",
2801
+ media_error_step: "peer_connection",
2802
+ ...input.grantedMedia
2803
+ } : void 0);
2693
2804
  this.record({
2694
2805
  event: "recovery_exhausted",
2695
2806
  state,
2696
2807
  epoch,
2697
2808
  code,
2809
+ ...media ? { media } : {},
2698
2810
  recovery: {
2699
2811
  reference: `connection.${code}`,
2700
2812
  attempts,
@@ -2726,7 +2838,8 @@ var ConnectionDiagnostics = class {
2726
2838
  ...input.parentSpanId ? { parentSpanId: input.parentSpanId } : {},
2727
2839
  outcome: end.outcome,
2728
2840
  durationMs: Math.max(0, this.#now() - startedAt),
2729
- ...end.code ? { code: end.code } : {}
2841
+ ...end.code ? { code: end.code } : {},
2842
+ ...end.media ? { media: end.media } : {}
2730
2843
  });
2731
2844
  }
2732
2845
  };
@@ -2740,8 +2853,7 @@ var ConnectionDiagnostics = class {
2740
2853
  var MEDIA_RECOVERY_TIMEOUT_MS = 6e4;
2741
2854
  var START_TIMEOUT_MS = 3e4;
2742
2855
  var LEAVE_TIMEOUT_MS = 5e3;
2743
- var RECOVERY_BUDGET_MS = 1e4;
2744
- var SYNC_RECOVERY_BUDGET_MS = MEDIA_RECOVERY_TIMEOUT_MS;
2856
+ var RECOVERY_BUDGET_MS = MEDIA_RECOVERY_TIMEOUT_MS;
2745
2857
  var MAX_RECOVERY_ATTEMPTS = 3;
2746
2858
  var REFRESH_RETRY_MS = 5e3;
2747
2859
  var RECONNECT_GRACE_MS = 250;
@@ -2783,6 +2895,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2783
2895
  const context = yield* Effect2.context();
2784
2896
  const serviceScope = yield* Effect2.scope;
2785
2897
  const reporter = platform.createConnectionReporter?.(access.currentUnsafe);
2898
+ let grantedMedia = { granted_audio: "unknown", granted_video: "unknown" };
2899
+ let mediaFailure;
2786
2900
  const diagnostics = new ConnectionDiagnostics({
2787
2901
  now: () => clock.currentTimeMillisUnsafe(),
2788
2902
  ...options.diagnostics,
@@ -2802,6 +2916,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2802
2916
  let fallbackIdentifier = 0;
2803
2917
  let emittedPorts = null;
2804
2918
  let foregroundRecoveryDeadline = 0;
2919
+ let pendingEpisodeEnd = null;
2920
+ let rejectionCheck = null;
2805
2921
  const model = {
2806
2922
  state: "idle",
2807
2923
  sync: null,
@@ -2833,7 +2949,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2833
2949
  epoch: 0
2834
2950
  };
2835
2951
  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) });
2952
+ 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
2953
  const withinForegroundBudget = (effect, milliseconds2, failure2) => Effect2.raceFirst(
2838
2954
  effect,
2839
2955
  Effect2.callback((resume) => {
@@ -2883,9 +2999,9 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2883
2999
  });
2884
3000
  const enqueue = (effect, join = false) => Effect2.gen(function* () {
2885
3001
  if (model.closed) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed"));
2886
- const deferred = yield* Deferred.make();
3002
+ const deferred = yield* Deferred2.make();
2887
3003
  yield* Queue.offer(queue, { effect, deferred, join });
2888
- return yield* Deferred.await(deferred);
3004
+ return yield* Deferred2.await(deferred);
2889
3005
  });
2890
3006
  const enqueueBackground = (effect) => {
2891
3007
  void Effect2.runForkWith(context)(enqueue(effect).pipe(Effect2.ignore));
@@ -3041,7 +3157,13 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3041
3157
  const span = diagnostics.startSpan({ step, state: model.state, epoch: model.epoch });
3042
3158
  return effect.pipe(
3043
3159
  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 })))
3160
+ Effect2.tapError(
3161
+ (cause) => Effect2.sync(() => {
3162
+ 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;
3163
+ if (media) mediaFailure = media;
3164
+ span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failureFromCause(cause, "internal_error", "Connection step failed").code, ...media ? { media } : {} });
3165
+ })
3166
+ )
3045
3167
  );
3046
3168
  };
3047
3169
  const confirmSyncEntry = (sync) => {
@@ -3052,6 +3174,10 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3052
3174
  if (model.state === "live") return;
3053
3175
  if (model.state === "leaving" || model.closed) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Cannot join an inactive Connection"));
3054
3176
  model.epoch += 1;
3177
+ pendingEpisodeEnd = null;
3178
+ rejectionCheck = null;
3179
+ grantedMedia = { granted_audio: "unknown", granted_video: "unknown" };
3180
+ mediaFailure = void 0;
3055
3181
  model.failure = null;
3056
3182
  model.episodeEndConfirmed = false;
3057
3183
  model.episodeEndReason = void 0;
@@ -3066,6 +3192,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3066
3192
  if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
3067
3193
  const captured = yield* trace("acquire_initial_media", acquireInitialMedia(model.initialMedia, model.initialMediaIntent?.() ?? model.intent));
3068
3194
  stream = captured.stream;
3195
+ grantedMedia = grantedMediaFromStream(stream);
3069
3196
  captureWarning = captured.failure;
3070
3197
  if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
3071
3198
  const grant = yield* trace("access_initialize", reason === "access_retry" ? access.refreshAfterRejection() : access.initialize(reason));
@@ -3082,7 +3209,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3082
3209
  }),
3083
3210
  onTransportDiagnostic,
3084
3211
  traceparent: reporter?.traceparent,
3085
- onFailure: () => enqueueBackground(handleMediaFailure()),
3212
+ onFailure: (cause, binding = grant.media.clientPayload.connectionId) => enqueueBackground(handleMediaFailure(media, cause, binding)),
3086
3213
  onScreenEnded: () => enqueueBackground(notifyScreenEnded())
3087
3214
  }),
3088
3215
  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 +3229,11 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3102
3229
  [
3103
3230
  trace(
3104
3231
  "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")
3232
+ (model.startInitialMedia ? model.startInitialMedia(media, stream) : Effect2.tryPromise({ try: () => media.start(stream), catch: (cause) => cause })).pipe(
3233
+ Effect2.mapError(
3234
+ (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")
3235
+ )
3236
+ )
3106
3237
  ),
3107
3238
  trace(
3108
3239
  "start_sync",
@@ -3120,14 +3251,20 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3120
3251
  Effect2.matchEffect({
3121
3252
  onFailure: (cause) => Effect2.gen(function* () {
3122
3253
  stopStream(stream);
3123
- const failure2 = failureFromCause(cause, "internal_error", "The Connection could not start");
3254
+ const originalFailure = failureFromCause(cause, "internal_error", "The Connection could not start");
3255
+ const failure2 = yield* resolveMediaRejection(originalFailure);
3256
+ if (failure2.code === "episode_ended" && needsEpisodeCheck(originalFailure)) {
3257
+ span.end({ state: model.state, epoch: model.epoch, outcome: "cancelled", code: "episode_ended" });
3258
+ yield* finishEpisodeEnded();
3259
+ return;
3260
+ }
3124
3261
  model.failure = toFailure(failure2);
3125
3262
  if (model.joinCancelled) {
3126
3263
  span.end({ state: model.state, epoch: model.epoch, outcome: "cancelled", code: "invalid_state" });
3127
3264
  return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
3128
3265
  }
3129
3266
  span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failure2.code });
3130
- const retry = !retried && failure2.code === "invalid_access";
3267
+ const retry = !retried && failure2.code === "invalid_access" && !sfuAccessRejected(failure2.cause);
3131
3268
  if (!retry) yield* recordFailure(failure2);
3132
3269
  model.admissionCleanupConfirmed = yield* cancelPendingAdmission();
3133
3270
  yield* stopPorts();
@@ -3272,7 +3409,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3272
3409
  }
3273
3410
  const code = plan.kind === "media" ? "media_recovery_exhausted" : "sync_recovery_exhausted";
3274
3411
  const transport = model.sync?.getSnapshot().connection.recoveryDiagnostic ?? null;
3275
- diagnostics.recordRecoveryExhausted({ state: model.state, epoch, code, attempts, transport });
3412
+ diagnostics.recordRecoveryExhausted({ state: model.state, epoch, code, attempts, transport, media: mediaFailure, grantedMedia });
3276
3413
  if (plan.kind === "sync") {
3277
3414
  yield* failForSnapshot(lifecycleFailure(code, true, "Connection lost. Rejoin to continue."));
3278
3415
  return;
@@ -3293,13 +3430,13 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3293
3430
  const recoveryPlan = (kind) => Clock2.currentTimeMillis.pipe(
3294
3431
  Effect2.map((now) => ({
3295
3432
  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]
3433
+ deadline: now + boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4),
3434
+ attempts: kind === "sync" ? boundedInteger(options.recovery?.maxAttempts, MAX_RECOVERY_ATTEMPTS, 1, 10) : null,
3435
+ delays: options.recovery?.backoffMs?.length ? options.recovery.backoffMs : kind === "media" ? [1e3, 2e3, 4e3, 6e3] : [100, 250, 500]
3299
3436
  }))
3300
3437
  );
3301
3438
  const attemptRecovery = (plan, mediaAttempt2, syncAttempt, onAttempt) => Effect2.gen(function* () {
3302
- for (let attempt = 1; attempt <= plan.attempts; attempt += 1) {
3439
+ for (let attempt = 1; plan.attempts === null || attempt <= plan.attempts; attempt += 1) {
3303
3440
  const remaining = plan.deadline - (yield* Clock2.currentTimeMillis);
3304
3441
  if (remaining <= 0) return null;
3305
3442
  onAttempt();
@@ -3310,7 +3447,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3310
3447
  );
3311
3448
  if (failure2 === null) return attempt;
3312
3449
  if (!failure2.recoverable) return yield* Effect2.fail(failure2);
3313
- if (attempt < plan.attempts) {
3450
+ if (plan.attempts === null || attempt < plan.attempts) {
3314
3451
  const remaining2 = Math.max(0, plan.deadline - (yield* Clock2.currentTimeMillis));
3315
3452
  yield* Effect2.sleep(Math.min(recoveryDelay(plan, attempt), remaining2));
3316
3453
  }
@@ -3327,10 +3464,13 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3327
3464
  });
3328
3465
  const recoveryDelay = (plan, attempt) => {
3329
3466
  const delay = plan.delays[Math.min(attempt - 1, plan.delays.length - 1)] ?? 0;
3330
- return Number.isFinite(delay) ? Math.max(0, delay) : 0;
3467
+ const finiteDelay = Number.isFinite(delay) ? Math.max(0, delay) : 0;
3468
+ const mediaMinimum = [1e3, 2e3, 4e3][attempt - 1] ?? 6e3;
3469
+ return plan.kind === "media" ? Math.min(6e3, Math.max(mediaMinimum, finiteDelay)) : finiteDelay;
3331
3470
  };
3332
3471
  const finishRecovery = (attempt, epoch) => Effect2.gen(function* () {
3333
3472
  if (model.epoch !== epoch || !active(model)) return;
3473
+ mediaFailure = void 0;
3334
3474
  model.recoveryQueued = null;
3335
3475
  model.syncSnapshot = model.sync?.getSnapshot() ?? null;
3336
3476
  model.mediaSnapshot = model.media?.getSnapshot() ?? null;
@@ -3390,7 +3530,20 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3390
3530
  attempt.expiresAt = (yield* Clock2.currentTimeMillis) + MEDIA_RECOVERY_TIMEOUT_MS;
3391
3531
  yield* foreign7(() => media.restart(restartInput), "media_start_failed", "Media transport could not restart");
3392
3532
  yield* waitForMediaLive(media);
3393
- });
3533
+ }).pipe(
3534
+ Effect2.tapError(
3535
+ (cause) => Effect2.sync(() => {
3536
+ const step = cause.code === "access_unavailable" || cause.code === "invalid_access" ? "access_refresh" : "peer_connection";
3537
+ const snapshotDiagnostic = cause.cause === void 0 && step === "peer_connection" ? model.media?.getSnapshot().failure?.diagnostic : void 0;
3538
+ mediaFailure = { ...snapshotDiagnostic ?? classifyMediaFailure(cause, step), ...grantedMedia };
3539
+ })
3540
+ ),
3541
+ Effect2.tap(
3542
+ () => Effect2.sync(() => {
3543
+ mediaFailure = void 0;
3544
+ })
3545
+ )
3546
+ );
3394
3547
  const syncAttemptExpired = (attempt, sync, now) => {
3395
3548
  if (attempt.client !== sync) {
3396
3549
  attempt.client = sync;
@@ -3418,7 +3571,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3418
3571
  attempt.expiresAt = now + MEDIA_RECOVERY_TIMEOUT_MS;
3419
3572
  }
3420
3573
  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));
3574
+ yield* waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4));
3422
3575
  });
3423
3576
  const restartSync = (attempt) => Effect2.gen(function* () {
3424
3577
  const epoch = model.epoch;
@@ -3449,7 +3602,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3449
3602
  else if (attempt.client === sync) attempt.started = true;
3450
3603
  })
3451
3604
  ).pipe(
3452
- Effect2.andThen(waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, SYNC_RECOVERY_BUDGET_MS, 1, 6e4))),
3605
+ Effect2.andThen(waitForSyncLive(sync, boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4))),
3453
3606
  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
3607
  Effect2.tapError(
3455
3608
  () => enqueue(
@@ -3502,14 +3655,111 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3502
3655
  const syncNeedsRecovery = (snapshot) => active(model) && (snapshot.connection.phase === "connecting" || snapshot.connection.phase === "recovering" || snapshot.connection.phase === "terminal");
3503
3656
  const mediaNeedsRecovery = (snapshot) => active(model) && (snapshot.connection.phase === "recovering" || snapshot.connection.phase === "failed" && snapshot.failure?.recoverable === true);
3504
3657
  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 } : {} });
3658
+ diagnostics.record({ event: "connection_failed", state: model.state, epoch: model.epoch, code: failure2.code, ...failure2.sync ? { sync: failure2.sync } : {}, ...mediaFailure ? { media: mediaFailure } : {} });
3506
3659
  });
3507
- const failForSnapshot = (failure2) => Effect2.gen(function* failForSnapshotEffect() {
3660
+ const finishEpisodeEnded = () => Effect2.gen(function* () {
3661
+ model.episodeEndConfirmed = true;
3662
+ model.syncSnapshot = model.sync?.getSnapshot() ?? model.syncSnapshot;
3663
+ model.episodeEndReason = model.syncSnapshot?.control?.endReason;
3664
+ yield* finishTerminalSnapshot();
3665
+ });
3666
+ const needsEpisodeCheck = (failure2) => failure2.code === "invalid_access" || failure2.cause instanceof CloudflareSFUError && failure2.cause.options.status === 410;
3667
+ const episodeHasEnded = () => {
3668
+ const snapshot = model.sync?.getSnapshot();
3669
+ return model.episodeEndConfirmed || snapshot?.control?.status === "ended" || snapshot?.connection.terminalReason === "episode_ended";
3670
+ };
3671
+ const stopRejectedMedia = () => {
3672
+ model.mediaBindingCleanup?.();
3673
+ model.mediaSnapshot = model.media?.getSnapshot() ?? null;
3674
+ };
3675
+ const waitForPendingEpisodeEnd = Effect2.suspend(() => pendingEpisodeEnd ? Deferred2.await(pendingEpisodeEnd) : Effect2.void);
3676
+ const resolveMediaRejection = (failure2) => {
3677
+ if (!needsEpisodeCheck(failure2)) return Effect2.succeed(failure2);
3678
+ if (failure2.code === "invalid_access") stopRejectedMedia();
3679
+ const epoch = model.epoch;
3680
+ const original = access.currentUnsafe();
3681
+ if (!original) return Effect2.succeed(failure2);
3682
+ return checkMediaRejection(failure2, original, epoch);
3683
+ };
3684
+ const checkMediaRejection = (failure2, original, epoch) => Effect2.gen(function* () {
3685
+ let attempt = 0;
3686
+ let refreshedAfterAuthRejection = false;
3687
+ let rejectedToken = null;
3688
+ while (model.epoch === epoch) {
3689
+ yield* waitForPendingEpisodeEnd;
3690
+ if (episodeHasEnded()) return lifecycleFailure("episode_ended", false, "The Episode has ended");
3691
+ const current = access.currentUnsafe();
3692
+ const grant = sameBindingCredential(current, original);
3693
+ const result = yield* readEpisodeStatus(platform.getEpisodeStatus, grant, rejectedToken);
3694
+ yield* waitForPendingEpisodeEnd;
3695
+ if (episodeHasEnded() || statusIsEnded(result.status)) return lifecycleFailure("episode_ended", false, "The Episode has ended");
3696
+ if (result.status === "active") return failure2;
3697
+ const error2 = result.cause;
3698
+ if (statusCredentialsRejected(error2)) {
3699
+ rejectedToken = grant.media.token;
3700
+ if (!refreshedAfterAuthRejection) {
3701
+ refreshedAfterAuthRejection = true;
3702
+ yield* access.refresh("scheduled_refresh", false).pipe(Effect2.catch(() => Effect2.succeed(null)));
3703
+ continue;
3704
+ }
3705
+ }
3706
+ yield* Effect2.sleep(episodeStatusRetryDelay(attempt++, error2));
3707
+ }
3708
+ return lifecycleFailure("invalid_state", false, "The connection changed");
3709
+ }).pipe(
3710
+ Effect2.timeout(6e4),
3711
+ 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.")))
3712
+ );
3713
+ const commitSnapshotFailure = (failure2) => Effect2.gen(function* () {
3714
+ if (failure2.code === "episode_ended") return yield* finishEpisodeEnded();
3508
3715
  model.failure = toFailure(failure2);
3509
3716
  yield* recordFailure(failure2);
3510
3717
  yield* stopPorts();
3511
3718
  yield* transition("failed");
3512
3719
  });
3720
+ const failForSnapshot = (failure2) => {
3721
+ if (!needsEpisodeCheck(failure2)) return commitSnapshotFailure(failure2);
3722
+ if (failure2.code === "invalid_access") stopRejectedMedia();
3723
+ const scope = model.activeScope;
3724
+ const epoch = model.epoch;
3725
+ const binding = access.currentUnsafe()?.media.clientPayload.connectionId;
3726
+ if (!scope) return Effect2.void;
3727
+ if (rejectionCheck?.epoch === epoch && rejectionCheck.binding === binding) {
3728
+ if (failure2.code === "invalid_access") rejectionCheck.failure = failure2;
3729
+ return publish();
3730
+ }
3731
+ const check = { epoch, binding, failure: failure2 };
3732
+ rejectionCheck = check;
3733
+ const releaseBinding = binding ? Effect2.runSync(access.holdMediaBinding(binding)) : Effect2.void;
3734
+ return Effect2.gen(function* () {
3735
+ if (model.epoch !== epoch || model.activeScope !== scope) return;
3736
+ yield* publish();
3737
+ yield* Effect2.forkIn(
3738
+ resolveMediaRejection(failure2).pipe(
3739
+ Effect2.flatMap(
3740
+ (resolved) => enqueue(
3741
+ Effect2.suspend(() => {
3742
+ if (model.epoch !== epoch || !active(model)) return Effect2.void;
3743
+ const finalFailure = resolved === failure2 ? check.failure : resolved;
3744
+ if (finalFailure === failure2 && failure2.cause instanceof CloudflareSFUError && failure2.cause.options.status === 410) {
3745
+ rejectionCheck = null;
3746
+ return recover("media");
3747
+ }
3748
+ return commitSnapshotFailure(finalFailure);
3749
+ })
3750
+ )
3751
+ ),
3752
+ Effect2.ensuring(
3753
+ Effect2.gen(function* () {
3754
+ if (rejectionCheck === check) rejectionCheck = null;
3755
+ if (model.activeScope === scope) yield* releaseBinding;
3756
+ })
3757
+ )
3758
+ ),
3759
+ scope
3760
+ );
3761
+ });
3762
+ };
3513
3763
  const handleMediaSnapshot = (media, snapshot) => Effect2.gen(function* () {
3514
3764
  if (media !== model.media || media.getSnapshot() !== snapshot) return;
3515
3765
  if (snapshot === model.mediaSnapshot) return;
@@ -3519,7 +3769,12 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3519
3769
  if (mediaNeedsRecovery(snapshot)) yield* recover("media");
3520
3770
  yield* publish();
3521
3771
  });
3522
- const handleMediaFailure = () => model.media ? handleMediaSnapshot(model.media, model.media.getSnapshot()) : Effect2.void;
3772
+ const handleMediaFailure = (media, cause, binding) => {
3773
+ if (media !== model.media || binding !== access.currentUnsafe()?.media.clientPayload.connectionId) return Effect2.void;
3774
+ if (sfuAccessRejected(cause)) stopRejectedMedia();
3775
+ 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"));
3776
+ return handleMediaSnapshot(media, media.getSnapshot());
3777
+ };
3523
3778
  const applyAutomaticMediaReplacement = (grant) => Effect2.gen(function* () {
3524
3779
  const media = model.media;
3525
3780
  const scope = model.activeScope;
@@ -3535,7 +3790,12 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3535
3790
  "media_start_failed",
3536
3791
  "The media client could not apply a replacement access grant"
3537
3792
  ).pipe(
3538
- (effect) => withinForegroundBudget(effect, MEDIA_RECOVERY_TIMEOUT_MS, lifecycleFailure("media_start_failed", true, "Automatic media replacement did not finish")),
3793
+ (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"))),
3794
+ Effect2.tapError(
3795
+ (cause) => Effect2.sync(() => {
3796
+ mediaFailure = { ...classifyMediaFailure(cause, "peer_connection"), ...grantedMedia };
3797
+ })
3798
+ ),
3539
3799
  Effect2.match({ onSuccess: () => true, onFailure: () => false }),
3540
3800
  Effect2.flatMap(
3541
3801
  (restarted) => enqueue(
@@ -3569,8 +3829,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3569
3829
  const exit = yield* Effect2.exit(Fiber.join(fiber));
3570
3830
  activeJoin = null;
3571
3831
  joinDeferred = null;
3572
- yield* Deferred.done(work.deferred, exit);
3573
- }) : Deferred.complete(work.deferred, work.effect).pipe(Effect2.asVoid);
3832
+ yield* Deferred2.done(work.deferred, exit);
3833
+ }) : Deferred2.complete(work.deferred, work.effect).pipe(Effect2.asVoid);
3574
3834
  yield* Effect2.forkScoped(Queue.take(queue).pipe(Effect2.flatMap(process), Effect2.forever));
3575
3835
  const accessReplacementUnsubscribe = yield* access.subscribeAutomaticMediaReplacement((grant) => enqueueBackground(applyAutomaticMediaReplacement(grant)));
3576
3836
  const foregroundUnsubscribe = platform.subscribeForeground?.(() => {
@@ -3620,15 +3880,33 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3620
3880
  confirmEpisodeEnded: Effect2.sync(() => {
3621
3881
  model.episodeEndConfirmed = true;
3622
3882
  }),
3883
+ runEpisodeEnd: (operation) => Effect2.gen(function* () {
3884
+ const completed = yield* Deferred2.make();
3885
+ const epoch = model.epoch;
3886
+ pendingEpisodeEnd = completed;
3887
+ return yield* operation.pipe(
3888
+ Effect2.tap(
3889
+ () => Effect2.sync(() => {
3890
+ if (model.epoch === epoch) model.episodeEndConfirmed = true;
3891
+ })
3892
+ ),
3893
+ Effect2.ensuring(
3894
+ Effect2.gen(function* () {
3895
+ if (pendingEpisodeEnd === completed) pendingEpisodeEnd = null;
3896
+ yield* Deferred2.succeed(completed, void 0);
3897
+ })
3898
+ )
3899
+ );
3900
+ }),
3623
3901
  join: () => Effect2.suspend(() => {
3624
3902
  if (model.state === "live") return Effect2.void;
3625
- if (joinDeferred) return Deferred.await(joinDeferred);
3903
+ if (joinDeferred) return Deferred2.await(joinDeferred);
3626
3904
  return Effect2.gen(function* () {
3627
- const deferred = yield* Deferred.make();
3905
+ const deferred = yield* Deferred2.make();
3628
3906
  model.joinCancelled = false;
3629
3907
  joinDeferred = deferred;
3630
3908
  yield* Queue.offer(queue, { effect: performJoin(), deferred, join: true });
3631
- return yield* Deferred.await(deferred);
3909
+ return yield* Deferred2.await(deferred);
3632
3910
  });
3633
3911
  }),
3634
3912
  leave: () => Effect2.gen(function* () {
@@ -3656,7 +3934,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3656
3934
  nowUnsafe: () => clock.currentTimeMillisUnsafe(),
3657
3935
  scheduleUnsafe: (callback, milliseconds2) => platform.clock.setTimeout(callback, milliseconds2),
3658
3936
  cancelScheduleUnsafe: (handle) => platform.clock.clearTimeout(handle),
3659
- createId: () => platform.createId?.() ?? `local-${++fallbackIdentifier}`
3937
+ createId: () => platform.createId?.() ?? `local-${++fallbackIdentifier}`,
3938
+ recordTransportDiagnostic: onTransportDiagnostic
3660
3939
  };
3661
3940
  })
3662
3941
  );
@@ -3701,7 +3980,12 @@ function toFailure(value) {
3701
3980
  return Object.freeze({ code: value.code, action: null, recoverable: value.recoverable, message: value.message, ...value.sync ? { sync: value.sync } : {} });
3702
3981
  }
3703
3982
  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);
3983
+ const rejectedMessage = connectionSuperseded(value) ? "Access was rejected. Another tab or device may be using it. Rejoin explicitly to continue." : "Access was rejected";
3984
+ 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);
3985
+ }
3986
+ function connectionSuperseded(failure2) {
3987
+ const cause = failure2 instanceof ConnectionAccessFailure ? failure2.cause : failure2;
3988
+ return cause instanceof AccessGrantError ? cause.reason === "connection_superseded" : typeof cause === "object" && cause !== null && "code" in cause && cause.code === "participant.connection_superseded";
3705
3989
  }
3706
3990
  function captureFailure(cause) {
3707
3991
  if (cause instanceof ConnectionError && cause.cause === void 0) return lifecycleFailure(cause.code, cause.recoverable, cause.message, cause);
@@ -3757,15 +4041,19 @@ function initialCaptureWarning(failures) {
3757
4041
  return lifecycleFailure(first.failure.code, true, message, first.failure.cause);
3758
4042
  }
3759
4043
  function failureFromCause(cause, code, message) {
4044
+ if (sfuAccessRejected(cause)) return lifecycleFailure("invalid_access", false, "Media access was rejected. Rejoin explicitly to continue.", cause);
3760
4045
  if (cause instanceof ConnectionLifecycleFailure) return cause;
3761
4046
  if (cause instanceof ConnectionAccessFailure) return accessFailure(cause);
3762
- if (cause instanceof AccessGrantError) return lifecycleFailure("invalid_access", false, "Access was rejected", cause);
4047
+ if (cause instanceof AccessGrantError || connectionSuperseded(cause)) return accessFailure(new ConnectionAccessFailure({ code: "access.invalid", cause }));
3763
4048
  if (cause instanceof ConnectionError) return lifecycleFailure(cause.code, cause.recoverable, cause.message, cause);
3764
4049
  if (cause instanceof DOMException && cause.name === "NotAllowedError") return lifecycleFailure("permission_denied", true, "Media permission was denied", cause);
3765
4050
  return lifecycleFailure(code, true, message, cause);
3766
4051
  }
4052
+ function sfuAccessRejected(cause) {
4053
+ return cause instanceof CloudflareSFUError && (cause.options.status === 401 || cause.options.status === 403);
4054
+ }
3767
4055
  function accessRejected(cause) {
3768
- return cause instanceof ConnectionError ? cause.code === "invalid_access" : typeof cause === "object" && cause !== null && "code" in cause && cause.code === "access.invalid";
4056
+ 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
4057
  }
3770
4058
  function boundedInteger(value, fallback, minimum, maximum) {
3771
4059
  const result = value ?? fallback;
@@ -3795,6 +4083,7 @@ function syncTerminalFailure(snapshot) {
3795
4083
  const reason = snapshot.connection.terminalReason;
3796
4084
  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
4085
  if (reason === "episode_ended") return lifecycleFailure("episode_ended", false, "The Episode has ended");
4086
+ 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
4087
  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
4088
  if (reason === "entry_timeout") return lifecycleFailure("sync_start_failed", true, "Call entry was not confirmed in time");
3800
4089
  return lifecycleFailure("sync_start_failed", true, "The Sync transport needs recovery");
@@ -3809,6 +4098,22 @@ function syncStartupFailure(snapshot) {
3809
4098
  function syncAutoRecoverable(snapshot, failure2) {
3810
4099
  return failure2.recoverable && snapshot.connection.terminalReason !== "entry_timeout";
3811
4100
  }
4101
+ function statusIsEnded(status) {
4102
+ return status === "ending" || status === "ended";
4103
+ }
4104
+ function statusCredentialsRejected(error2) {
4105
+ return error2 instanceof EpisodeStatusError && (error2.status === 401 || error2.status === 403);
4106
+ }
4107
+ function readEpisodeStatus(read, grant, rejectedToken) {
4108
+ if (grant.media.token === rejectedToken) return Effect2.succeed({ status: null, cause: null });
4109
+ return Effect2.tryPromise({
4110
+ try: (signal) => read ? read(grant, signal) : Promise.reject(new Error("Episode status lookup unavailable")),
4111
+ catch: (cause) => cause
4112
+ }).pipe(Effect2.timeout(3e3), Effect2.match({ onSuccess: (status) => ({ status, cause: null }), onFailure: (cause) => ({ status: null, cause }) }));
4113
+ }
4114
+ function sameBindingCredential(current, original) {
4115
+ return current?.media.clientPayload.connectionId === original.media.clientPayload.connectionId ? current : original;
4116
+ }
3812
4117
 
3813
4118
  // src/space-client/errors.ts
3814
4119
  import { Data as Data3 } from "effect";
@@ -3909,7 +4214,7 @@ function connectionErrorCode(code, action) {
3909
4214
 
3910
4215
  // src/space-client/store.ts
3911
4216
  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"]);
4217
+ var INTERRUPTED_CONNECTION_PHASES = /* @__PURE__ */ new Set(["unresponsive", "connecting", "recovering", "stopped"]);
3913
4218
  var empty = (value) => Object.freeze(value);
3914
4219
  var EMPTY = empty({
3915
4220
  connection: empty({ status: "idle", episode: null, lastError: null, episodeEnded: false, removedByHost: false }),
@@ -4572,11 +4877,79 @@ function chatError(message) {
4572
4877
  }
4573
4878
 
4574
4879
  // 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";
4880
+ import { Clock as Clock3, Context as Context6, Deferred as Deferred3, Effect as Effect5, Exit as Exit2, Layer as Layer6, Semaphore } from "effect";
4881
+
4882
+ // src/space-client/media-projection-monitor.ts
4883
+ var GRACE_MS = 1e4;
4884
+ function projectionMatches(observation) {
4885
+ return observation.enabled === observation.projectedEnabled && (!observation.enabled || observation.publicationId === observation.projectedPublicationId);
4886
+ }
4887
+ var MediaProjectionMonitor = class {
4888
+ #options;
4889
+ #actions = /* @__PURE__ */ new Map();
4890
+ constructor(options) {
4891
+ this.#options = options;
4892
+ }
4893
+ observe(source, observation) {
4894
+ let action = this.#actions.get(source);
4895
+ if (action?.revision !== observation.revision) {
4896
+ this.#clear(source);
4897
+ action = { revision: observation.revision, mismatch: null, reported: false, failed: false };
4898
+ this.#actions.set(source, action);
4899
+ }
4900
+ if (action.failed) return;
4901
+ if (projectionMatches(observation)) {
4902
+ if (action.mismatch) this.#options.cancel(action.mismatch.timer);
4903
+ action.mismatch = null;
4904
+ return;
4905
+ }
4906
+ if (action.mismatch) {
4907
+ action.mismatch.observation = observation;
4908
+ if (observation.enabled && this.#options.now() - action.mismatch.startedAt >= GRACE_MS) this.#expire(source, action);
4909
+ return;
4910
+ }
4911
+ const timer = this.#options.schedule(() => this.#expire(source, action), GRACE_MS);
4912
+ action.mismatch = { observation, startedAt: this.#options.now(), timer };
4913
+ }
4914
+ state(source) {
4915
+ const action = this.#actions.get(source);
4916
+ if (action?.failed) return "failed";
4917
+ return action?.mismatch?.observation.enabled ? "requesting" : null;
4918
+ }
4919
+ reset() {
4920
+ for (const source of this.#actions.keys()) this.#clear(source);
4921
+ }
4922
+ #clear(source) {
4923
+ const action = this.#actions.get(source);
4924
+ if (action?.mismatch) this.#options.cancel(action.mismatch.timer);
4925
+ this.#actions.delete(source);
4926
+ }
4927
+ #expire(source, action) {
4928
+ if (this.#actions.get(source) !== action || !action.mismatch) return;
4929
+ const { observation, startedAt } = action.mismatch;
4930
+ action.failed = observation.enabled;
4931
+ if (observation.enabled) this.#options.pause(source, observation.revision);
4932
+ if (!action.reported) {
4933
+ action.reported = true;
4934
+ this.#options.report({
4935
+ event: "media_projection_mismatch",
4936
+ source,
4937
+ publication_state: observation.enabled ? "on" : "off",
4938
+ projection_state: observation.projectedEnabled ? "on" : "off",
4939
+ duration_ms: this.#options.now() - startedAt,
4940
+ attempt: 0,
4941
+ outcome: observation.enabled ? "failed" : "started"
4942
+ });
4943
+ }
4944
+ this.#options.changed();
4945
+ }
4946
+ };
4947
+
4948
+ // src/space-client/media-controller.ts
4576
4949
  var MEDIA_SOURCES = ["microphone", "camera", "screen"];
4577
4950
  var MEDIA_READY_TIMEOUT_MS = 1e4;
4578
4951
  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 };
4952
+ return { desired: { enabled, revision: 0, controlRevision: -1, previousEnabled: enabled }, result: { revision: 0, status: "confirmed" }, initialRevision: 0, localEnabled: enabled, pause: null, serverSequence: -1, forcedRevision: null, pausedPublicationId: null };
4580
4953
  }
4581
4954
  var MediaControllerService = class extends Context6.Service()("@chalk/client/MediaController") {
4582
4955
  };
@@ -4619,6 +4992,7 @@ var MediaControllerRuntime = class {
4619
4992
  #tracks = /* @__PURE__ */ new Map();
4620
4993
  #sources = { microphone: sourceWorker(true), camera: sourceWorker(true), screen: sourceWorker(false) };
4621
4994
  #syncLive = false;
4995
+ #recovering = null;
4622
4996
  #pendingSources = /* @__PURE__ */ new Map();
4623
4997
  #pendingCaptures = /* @__PURE__ */ new Set();
4624
4998
  #readinessChecks = /* @__PURE__ */ new Set();
@@ -4634,6 +5008,8 @@ var MediaControllerRuntime = class {
4634
5008
  #unsubscribeConnection = null;
4635
5009
  #unsubscribeScreenEnded = null;
4636
5010
  #unsubscribePorts = null;
5011
+ #microphoneWatch = null;
5012
+ #projection;
4637
5013
  constructor(connection, store, selection, gates, now, fork) {
4638
5014
  this.#connection = connection;
4639
5015
  this.#store = store;
@@ -4641,6 +5017,16 @@ var MediaControllerRuntime = class {
4641
5017
  this.#gates = gates;
4642
5018
  this.#now = now;
4643
5019
  this.#fork = fork;
5020
+ this.#projection = new MediaProjectionMonitor({
5021
+ now,
5022
+ schedule: connection.scheduleUnsafe,
5023
+ cancel: connection.cancelScheduleUnsafe,
5024
+ changed: () => this.#publish(),
5025
+ report: (event) => connection.recordTransportDiagnostic(event),
5026
+ pause: (source, revision) => {
5027
+ if (this.#ports) this.#pausePublication(this.#ports, source, revision);
5028
+ }
5029
+ });
4644
5030
  this.#unsubscribeConnection = connection.subscribe(() => this.#handleConnectionChange());
4645
5031
  this.#unsubscribeScreenEnded = connection.subscribeScreenEnded(() => this.#handleScreenEnded());
4646
5032
  this.#unsubscribePorts = connection.subscribePorts((ports) => this.#bind(ports));
@@ -4688,7 +5074,7 @@ var MediaControllerRuntime = class {
4688
5074
  }).pipe(Effect5.mapError(normalizeMediaError));
4689
5075
  };
4690
5076
  initialIntent() {
4691
- return { microphone: this.#sources.microphone.desired.enabled, camera: this.#sources.camera.desired.enabled };
5077
+ return { microphone: this.#hasConsent("microphone"), camera: this.#hasConsent("camera") };
4692
5078
  }
4693
5079
  captureInitial(intent) {
4694
5080
  for (const source of ["microphone", "camera"]) this.#sources[source].initialRevision = this.#sources[source].desired.revision;
@@ -4744,6 +5130,7 @@ var MediaControllerRuntime = class {
4744
5130
  this.#fork(this.#refreshDevices().pipe(Effect5.ignore));
4745
5131
  }
4746
5132
  dispose() {
5133
+ this.#projection.reset();
4747
5134
  for (const check of this.#readinessChecks) check(normalizeMediaError(cancelledCaptureError()));
4748
5135
  this.#cancelCaptures();
4749
5136
  for (const unsubscribe of [this.#unsubscribeMedia, this.#unsubscribeRequests, this.#unsubscribePause, this.#unsubscribeSync]) unsubscribe?.();
@@ -4759,9 +5146,6 @@ var MediaControllerRuntime = class {
4759
5146
  this.#unsubscribePorts = null;
4760
5147
  this.#ports = null;
4761
5148
  this.#resetObservations();
4762
- this.#screenAudioTrack?.stop();
4763
- this.#screenAudioTrack = null;
4764
- for (const track of this.#tracks.values()) track.stop();
4765
5149
  this.#clearTracks();
4766
5150
  this.#clearRequests();
4767
5151
  this.#publish();
@@ -4777,10 +5161,11 @@ var MediaControllerRuntime = class {
4777
5161
  const check = (failure2) => {
4778
5162
  if (failure2) return resume(Effect5.fail(failure2));
4779
5163
  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())));
5164
+ if (ports && !this.#canUsePorts(ports)) return resume(Effect5.fail(normalizeMediaError(cancelledCaptureError())));
4781
5165
  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"))));
5166
+ const phases = [state, this.#ports?.sync.getSnapshot().connection.phase, this.#store.getSnapshot().connection.status];
5167
+ if (phases.every((phase) => phase === "live")) return resume(Effect5.void);
5168
+ if (!["joining", "reconnecting", "live"].includes(state)) resume(Effect5.fail(normalizeMediaError(new V1SyncError("Media enable requires a live call", "not_live"))));
4784
5169
  };
4785
5170
  this.#readinessChecks.add(check);
4786
5171
  const unsubscribe = this.#connection.subscribe(() => check());
@@ -4810,11 +5195,20 @@ var MediaControllerRuntime = class {
4810
5195
  #pauseTracks(source) {
4811
5196
  const track = this.#track(source);
4812
5197
  if (track) track.enabled = false;
5198
+ for (const publication of this.#ports?.media.getSnapshot().localTracks ?? []) if (publication.source === source) publication.track.enabled = false;
4813
5199
  if (source === "screen" && this.#screenAudioTrack) this.#screenAudioTrack.enabled = false;
4814
5200
  }
5201
+ #retainPause(state, next) {
5202
+ const previous = state.pause;
5203
+ state.pause = previous ? Promise.allSettled([previous, next]).then((results) => {
5204
+ for (const result of results) if (result.status === "rejected") throw result.reason;
5205
+ }) : next;
5206
+ void state.pause.catch(() => void 0);
5207
+ }
4815
5208
  #recordIntent(source, enabled, options) {
4816
5209
  const state = this.#sources[source];
4817
- const desired = { enabled, revision: state.desired.revision + 1, options, previousEnabled: state.localEnabled };
5210
+ const controlRevision = Math.max(state.serverSequence, this.#ports?.sync.getSnapshot().control?.revision ?? -1);
5211
+ const desired = { enabled, revision: state.desired.revision + 1, controlRevision, options, previousEnabled: state.localEnabled };
4818
5212
  state.desired = desired;
4819
5213
  if (!enabled) {
4820
5214
  state.localEnabled = false;
@@ -4826,12 +5220,7 @@ var MediaControllerRuntime = class {
4826
5220
  const ports = this.#ports;
4827
5221
  if (ports) {
4828
5222
  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);
5223
+ this.#retainPause(state, this.#beginLocalPause(ports, source));
4835
5224
  }
4836
5225
  }
4837
5226
  for (const check of this.#readinessChecks) check();
@@ -4839,6 +5228,7 @@ var MediaControllerRuntime = class {
4839
5228
  }
4840
5229
  #act(source, enabled, options) {
4841
5230
  return Effect5.suspend(() => {
5231
+ if (this.#ports?.media.getSnapshot().connection.phase === "stopped") return Effect5.void;
4842
5232
  const desired = this.#recordIntent(source, enabled, options);
4843
5233
  const ready = enabled && source !== "screen" && this.#store.getSnapshot().connection.status !== "live" ? this.#waitForLive(source, desired.revision).pipe(
4844
5234
  Effect5.tapError(
@@ -4923,7 +5313,16 @@ var MediaControllerRuntime = class {
4923
5313
  const state = this.#sources[source];
4924
5314
  if (!intent.enabled && ports.sync.getSnapshot().connection.phase !== "live") return;
4925
5315
  const previous = intent.previousEnabled;
4926
- if (state.desired.revision === intent.revision) state.result = { revision: intent.revision, status: "failed", cause, cleanup: !intent.enabled || !previous };
5316
+ if (state.desired.revision !== intent.revision) return;
5317
+ state.result = { revision: intent.revision, status: "failed", cause, cleanup: !intent.enabled || !previous };
5318
+ if (state.result.cleanup) {
5319
+ if (intent.enabled && source !== "screen") this.#pausePublication(ports, source, intent.revision);
5320
+ else {
5321
+ state.localEnabled = false;
5322
+ this.#pauseTracks(source);
5323
+ ports.media.setLocalSourceIntent?.(source, false);
5324
+ }
5325
+ }
4927
5326
  });
4928
5327
  }
4929
5328
  #prepareDevice(ports, source, intent) {
@@ -5033,21 +5432,26 @@ var MediaControllerRuntime = class {
5033
5432
  })
5034
5433
  );
5035
5434
  }
5036
- #assertCurrentIntent(source, revision) {
5037
- if (this.#sources[source].desired.revision !== revision) throw cancelledCaptureError();
5435
+ #assertCurrentIntent(source, revision, enabling = true) {
5436
+ const state = this.#sources[source];
5437
+ if (state.desired.revision !== revision || enabling && state.forcedRevision === revision) throw cancelledCaptureError();
5438
+ }
5439
+ #hasConsent(source) {
5440
+ const state = this.#sources[source];
5441
+ return state.desired.enabled && state.forcedRevision !== state.desired.revision;
5038
5442
  }
5039
5443
  #resetObservations() {
5444
+ if (this.#connection.getSnapshot().state !== "reconnecting") this.#recovering = null;
5040
5445
  for (const source of MEDIA_SOURCES) {
5041
5446
  const state = this.#sources[source];
5042
5447
  state.result = { revision: state.desired.revision, status: "confirmed" };
5043
5448
  state.pause = null;
5044
5449
  state.serverSequence = -1;
5045
- state.forcedRevision = null;
5046
5450
  }
5047
5451
  }
5048
5452
  #pauseInitialTracks() {
5049
5453
  for (const source of ["microphone", "camera"]) {
5050
- const enabled = this.#sources[source].desired.enabled;
5454
+ const enabled = this.#hasConsent(source);
5051
5455
  const track = this.#track(source);
5052
5456
  if (track && !enabled) track.enabled = false;
5053
5457
  this.#sources[source].localEnabled = !!track && enabled;
@@ -5058,15 +5462,14 @@ var MediaControllerRuntime = class {
5058
5462
  const revisions = { microphone: this.#sources.microphone.initialRevision, camera: this.#sources.camera.initialRevision };
5059
5463
  const tracks = stream.getTracks().filter((track) => {
5060
5464
  const source = track.kind === "audio" ? "microphone" : "camera";
5061
- const state = this.#sources[source];
5062
- track.enabled = state.desired.enabled;
5063
- if (!state.desired.enabled) {
5465
+ const enabled = this.#hasConsent(source);
5466
+ track.enabled = enabled;
5467
+ if (!enabled) {
5064
5468
  this.#deleteTrack(source);
5065
5469
  if (source === "microphone") track.stop();
5066
5470
  }
5067
- return state.desired.enabled;
5471
+ return enabled;
5068
5472
  });
5069
- for (const source of ["microphone", "camera"]) this.#sources[source].sentRevision = revisions[source];
5070
5473
  await media.start(streamFromTracks(tracks));
5071
5474
  for (const source of ["microphone", "camera"]) {
5072
5475
  const state = this.#sources[source];
@@ -5079,11 +5482,10 @@ var MediaControllerRuntime = class {
5079
5482
  const command = this.#mediaAction(
5080
5483
  enabled,
5081
5484
  foreign2(async () => {
5082
- this.#assertCurrentIntent(source, intent.revision);
5485
+ this.#assertCurrentIntent(source, intent.revision, enabled);
5083
5486
  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));
5487
+ const send = source === "microphone" ? ports.sync.setMicrophoneEnabled : source === "camera" ? ports.sync.setCameraEnabled : ports.sync.setScreenShareEnabled;
5488
+ await send.call(ports.sync, enabled);
5087
5489
  }).pipe(
5088
5490
  Effect5.andThen(enabled && source !== "screen" ? this.#waitForLive(source, intent.revision, ports) : Effect5.void),
5089
5491
  Effect5.tap(() => Effect5.try({ try: () => this.#assertActivePorts(ports, source === "screen" ? "startScreenShare" : source === "camera" ? "setCameraEnabled" : "setMicrophoneEnabled"), catch: (cause) => cause }))
@@ -5093,10 +5495,19 @@ var MediaControllerRuntime = class {
5093
5495
  }
5094
5496
  #reconcileIntents() {
5095
5497
  const live = this.#connection.getSnapshot().state === "live" && this.#ports?.sync.getSnapshot().connection.phase === "live";
5498
+ if (this.#syncLive && !live) this.#rememberRecoveryIntents();
5096
5499
  const resumed = live && !this.#syncLive;
5097
5500
  this.#syncLive = live;
5098
5501
  if (!resumed) return;
5099
- for (const source of MEDIA_SOURCES) this.#fork(this.#serialize(source, this.#work(source)).pipe(Effect5.ignore));
5502
+ if (this.#ports) this.#pauseRecoveredIntents(this.#ports, this.#ports.sync.getSnapshot());
5503
+ this.#resumePendingActions();
5504
+ }
5505
+ #resumePendingActions() {
5506
+ for (const source of MEDIA_SOURCES) {
5507
+ const state = this.#sources[source];
5508
+ if (source !== "screen" && (state.desired.enabled || state.result.revision === state.desired.revision)) continue;
5509
+ this.#fork(this.#serialize(source, this.#work(source)).pipe(Effect5.ignore));
5510
+ }
5100
5511
  }
5101
5512
  #clearScreenAudio(media) {
5102
5513
  return foreign2(() => media.clearPreparedLocalTrack("screen_audio")).pipe(
@@ -5109,7 +5520,8 @@ var MediaControllerRuntime = class {
5109
5520
  );
5110
5521
  }
5111
5522
  #beginLocalPause(ports, source) {
5112
- const pause = Effect5.runPromise(this.#localTarget(ports, source, false));
5523
+ this.#sources[source].pausedPublicationId = ports.media.getSnapshot().localTracks.find((item) => item.source === source)?.publicationId ?? null;
5524
+ const pause = ports.media.closeForcedLocalPublication ? Promise.resolve().then(() => ports.media.closeForcedLocalPublication(source)) : Effect5.runPromise(this.#localTarget(ports, source, false));
5113
5525
  void pause.catch(() => void 0);
5114
5526
  return pause;
5115
5527
  }
@@ -5178,18 +5590,53 @@ var MediaControllerRuntime = class {
5178
5590
  #setTrack(source, track) {
5179
5591
  if (source !== "camera") {
5180
5592
  this.#tracks.set(source, track);
5593
+ if (source === "microphone") this.#watchMicrophone(track);
5181
5594
  return;
5182
5595
  }
5183
5596
  const state = this.#camera;
5184
5597
  if (!("owner" in state)) throw cancelledCaptureError();
5185
5598
  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
- };
5599
+ const onEnded = () => this.#handleCameraEnded(track);
5189
5600
  this.#transitionCamera({ status: "live", owner: state.owner, track, publication: this.#ports?.media ?? "initial", onEnded });
5190
5601
  }
5602
+ #handleCameraEnded(track) {
5603
+ const state = this.#camera;
5604
+ if (state.status !== "live" || state.track !== track) return;
5605
+ if (this.#ports && state.publication === this.#ports.media) this.#pausePublication(this.#ports, "camera", this.#sources.camera.desired.revision);
5606
+ this.#transitionCamera({ status: "ended" });
5607
+ }
5608
+ #watchMicrophone(track) {
5609
+ if (this.#microphoneWatch?.track === track) return;
5610
+ this.#microphoneWatch?.unsubscribe();
5611
+ this.#microphoneWatch = null;
5612
+ if (!track) return;
5613
+ const changed = () => {
5614
+ if (this.#microphoneWatch?.track !== track) return;
5615
+ if (track.muted || track.readyState === "ended") {
5616
+ track.enabled = false;
5617
+ const state = this.#sources.microphone;
5618
+ if (this.#ports) this.#pausePublication(this.#ports, "microphone", state.desired.revision);
5619
+ else {
5620
+ state.forcedRevision = state.desired.revision;
5621
+ state.localEnabled = false;
5622
+ }
5623
+ }
5624
+ this.#publish();
5625
+ };
5626
+ for (const event of ["mute", "unmute", "ended"]) track.addEventListener(event, changed);
5627
+ this.#microphoneWatch = {
5628
+ track,
5629
+ unsubscribe: () => {
5630
+ for (const event of ["mute", "unmute", "ended"]) track.removeEventListener(event, changed);
5631
+ }
5632
+ };
5633
+ if (track.muted || track.readyState === "ended") changed();
5634
+ }
5191
5635
  #deleteTrack(source) {
5192
5636
  if (source !== "camera") {
5637
+ if (source === "microphone") {
5638
+ this.#watchMicrophone(null);
5639
+ }
5193
5640
  this.#tracks.delete(source);
5194
5641
  return;
5195
5642
  }
@@ -5200,6 +5647,7 @@ var MediaControllerRuntime = class {
5200
5647
  } else this.#transitionCamera({ status: "ended" });
5201
5648
  }
5202
5649
  #clearTracks() {
5650
+ this.#watchMicrophone(null);
5203
5651
  this.#tracks.clear();
5204
5652
  this.#transitionCamera({ status: "idle" });
5205
5653
  }
@@ -5242,11 +5690,11 @@ var MediaControllerRuntime = class {
5242
5690
  #beginCameraCapture(acquire) {
5243
5691
  const abort = new AbortController();
5244
5692
  const owner = {
5245
- result: Deferred2.makeUnsafe(),
5693
+ result: Deferred3.makeUnsafe(),
5246
5694
  references: 0,
5247
5695
  cancel: () => {
5248
5696
  abort.abort();
5249
- Deferred2.doneUnsafe(owner.result, Effect5.fail(cancelledCaptureError()));
5697
+ Deferred3.doneUnsafe(owner.result, Effect5.fail(cancelledCaptureError()));
5250
5698
  }
5251
5699
  };
5252
5700
  this.#transitionCamera({ status: "capturing", owner });
@@ -5260,9 +5708,7 @@ var MediaControllerRuntime = class {
5260
5708
  track.stop();
5261
5709
  throw cancelledCaptureError();
5262
5710
  }
5263
- const onEnded = () => {
5264
- if (this.#camera.status === "live" && this.#camera.owner === owner) this.#transitionCamera({ status: "ended" });
5265
- };
5711
+ const onEnded = () => this.#handleCameraEnded(track);
5266
5712
  this.#transitionCamera({ status: "live", owner, track, publication: state.status === "live" ? state.publication : null, onEnded });
5267
5713
  if (track.readyState !== "live") {
5268
5714
  this.#transitionCamera({ status: "ended" });
@@ -5272,7 +5718,7 @@ var MediaControllerRuntime = class {
5272
5718
  catch: (cause) => cause
5273
5719
  })
5274
5720
  ),
5275
- Effect5.onExit((exit) => Deferred2.done(owner.result, exit)),
5721
+ Effect5.onExit((exit) => Deferred3.done(owner.result, exit)),
5276
5722
  Effect5.ignore
5277
5723
  )
5278
5724
  );
@@ -5288,7 +5734,7 @@ var MediaControllerRuntime = class {
5288
5734
  const current = this.#camera;
5289
5735
  if (owner.references === 0 && "owner" in current && current.owner === owner && (current.status !== "live" || current.publication === null)) this.#transitionCamera({ status: "ended" });
5290
5736
  };
5291
- return Deferred2.await(owner.result).pipe(
5737
+ return Deferred3.await(owner.result).pipe(
5292
5738
  Effect5.flatMap((track) => track.readyState === "live" ? Effect5.succeed({ track, release }) : Effect5.fail(cancelledCaptureError())),
5293
5739
  Effect5.onExit(
5294
5740
  (exit) => Effect5.sync(() => {
@@ -5318,9 +5764,11 @@ var MediaControllerRuntime = class {
5318
5764
  return Effect5.suspend(() => {
5319
5765
  let cancelled = false;
5320
5766
  let captured = null;
5767
+ const cancellation = Deferred3.makeUnsafe();
5321
5768
  const cancel = () => {
5322
5769
  cancelled = true;
5323
5770
  stopStream(captured);
5771
+ Deferred3.doneUnsafe(cancellation, Effect5.fail(cancelledCaptureError()));
5324
5772
  };
5325
5773
  this.#pendingCaptures.add(cancel);
5326
5774
  signal?.addEventListener("abort", cancel, { once: true });
@@ -5329,7 +5777,7 @@ var MediaControllerRuntime = class {
5329
5777
  if (cancelled) throw cancelledCaptureError();
5330
5778
  const stream = await operation();
5331
5779
  captured = stream;
5332
- if (cancelled) {
5780
+ if (cancelled || this.#ports?.media.getSnapshot().connection.phase === "stopped") {
5333
5781
  stopStream(stream);
5334
5782
  throw cancelledCaptureError();
5335
5783
  }
@@ -5337,6 +5785,7 @@ var MediaControllerRuntime = class {
5337
5785
  }).pipe(
5338
5786
  Effect5.mapError(onCaptureError),
5339
5787
  Effect5.flatMap(use),
5788
+ Effect5.raceFirst(Deferred3.await(cancellation)),
5340
5789
  Effect5.tap(() => cancelled ? Effect5.fail(cancelledCaptureError()) : Effect5.void),
5341
5790
  Effect5.onExit(
5342
5791
  (exit) => Effect5.sync(() => {
@@ -5352,6 +5801,9 @@ var MediaControllerRuntime = class {
5352
5801
  this.#transitionCamera({ status: "ended" });
5353
5802
  for (const cancel of this.#pendingCaptures) cancel();
5354
5803
  this.#pendingCaptures.clear();
5804
+ this.#screenAudioTrack?.stop();
5805
+ this.#screenAudioTrack = null;
5806
+ for (const track of this.#tracks.values()) track.stop();
5355
5807
  }
5356
5808
  #captureSource(source, signal) {
5357
5809
  return this.#captureStream(
@@ -5408,42 +5860,46 @@ var MediaControllerRuntime = class {
5408
5860
  const failure2 = isPermissionDenied(input.cause) || captureWasUnavailable ? captureError(input.cause, input.action, input.permissionMessage) : input.cause;
5409
5861
  return rollback.pipe(Effect5.andThen(Effect5.sync(() => this.#publish())), Effect5.andThen(Effect5.fail(failure2)));
5410
5862
  }
5863
+ #canUsePorts(ports) {
5864
+ return this.#ports?.sync === ports.sync && this.#ports.media === ports.media && ports.media.getSnapshot().connection.phase !== "stopped";
5865
+ }
5411
5866
  #assertActivePorts(ports, action) {
5412
- if (this.#connection.getSnapshot().state === "live" && this.#ports?.sync === ports.sync && this.#ports.media === ports.media) return;
5867
+ if (this.#connection.getSnapshot().state === "live" && this.#canUsePorts(ports)) return;
5413
5868
  throw new ConnectionError({ code: "invalid_state", action, recoverable: false, message: `${action} belongs to an inactive connection` });
5414
5869
  }
5415
5870
  #pausePublication(ports, source, revision) {
5416
5871
  const state = this.#sources[source];
5417
- if (state.desired.revision !== revision || ports.sync.getSnapshot().control?.status === "ended") return;
5872
+ if (state.desired.revision !== revision || state.forcedRevision === revision || ports.sync.getSnapshot().control?.status === "ended") return;
5418
5873
  state.forcedRevision = revision;
5419
5874
  state.localEnabled = false;
5420
5875
  this.#pauseTracks(source);
5421
5876
  ports.media.setLocalSourceIntent?.(source, false);
5422
- if (ports.media.closeForcedLocalPublication) this.#fork(foreign2(() => ports.media.closeForcedLocalPublication(source)).pipe(Effect5.ignore));
5877
+ this.#retainPause(state, this.#beginLocalPause(ports, source));
5423
5878
  this.#publish();
5424
5879
  }
5425
5880
  #bind(ports) {
5881
+ if (ports?.media.getSnapshot().connection.phase === "stopped") this.#cancelCaptures();
5426
5882
  if (ports && this.#ports?.sync === ports.sync && this.#ports.media === ports.media) return;
5883
+ this.#rememberRecoveryIntents();
5427
5884
  for (const unsubscribe of [this.#unsubscribeMedia, this.#unsubscribeRequests, this.#unsubscribePause, this.#unsubscribeSync]) unsubscribe?.();
5428
5885
  this.#unsubscribeMedia = null;
5429
5886
  this.#unsubscribeRequests = null;
5430
5887
  this.#unsubscribePause = null;
5431
5888
  this.#unsubscribeSync = null;
5432
5889
  this.#ports = ports;
5890
+ this.#projection.reset();
5433
5891
  this.#syncLive = false;
5434
5892
  this.#bindCamera(ports);
5435
5893
  if (!ports) {
5436
5894
  this.#resetObservations();
5437
5895
  this.#cancelCaptures();
5438
- this.#screenAudioTrack?.stop();
5439
- this.#screenAudioTrack = null;
5440
- for (const track of this.#tracks.values()) track.stop();
5441
5896
  this.#clearTracks();
5442
5897
  this.#clearRequests();
5443
5898
  this.#publish();
5444
5899
  return;
5445
5900
  }
5446
5901
  this.#unsubscribeMedia = ports.media.subscribe(() => {
5902
+ if (ports.media.getSnapshot().connection.phase === "stopped") this.#cancelCaptures();
5447
5903
  this.#applyRemoteMediaState(ports, ports.sync.getSnapshot());
5448
5904
  this.#publish();
5449
5905
  });
@@ -5458,41 +5914,27 @@ var MediaControllerRuntime = class {
5458
5914
  const state = this.#sources[pause.source];
5459
5915
  if (pause.revision <= state.serverSequence) return;
5460
5916
  state.serverSequence = pause.revision;
5461
- this.#pausePublication(ports, pause.source, state.sentRevision);
5917
+ if (pause.revision > state.desired.controlRevision) this.#pausePublication(ports, pause.source, state.desired.revision);
5462
5918
  }) ?? null;
5463
5919
  }
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;
5920
+ #rememberRecoveryIntents() {
5921
+ if (!this.#ports) return;
5922
+ this.#recovering ??= new Map(MEDIA_SOURCES.filter((source) => this.#sources[source].desired.enabled).map((source) => [source, this.#sources[source].desired.revision]));
5473
5923
  }
5474
- #pauseLostPublications(ports, snapshot, publications) {
5475
- for (const [source, previous] of publications) {
5924
+ #pauseRecoveredIntents(ports, snapshot) {
5925
+ const recovering = this.#recovering;
5926
+ this.#recovering = null;
5927
+ for (const [source, revision] of recovering ?? []) {
5476
5928
  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);
5929
+ if (!current?.enabled) this.#pausePublication(ports, source, revision);
5478
5930
  }
5479
5931
  }
5480
5932
  #subscribeSync(ports) {
5481
- let recovering = /* @__PURE__ */ new Map();
5482
- let lastLive = ports.sync.getSnapshot();
5483
- let wasLive = lastLive.connection.phase === "live";
5484
5933
  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
5934
  this.#removeRequestsFromMissingParticipants(snapshot);
5494
5935
  this.#applyRemoteMediaState(ports, snapshot);
5495
5936
  this.#reconcileIntents();
5937
+ this.#publish();
5496
5938
  });
5497
5939
  }
5498
5940
  #bindCamera(ports) {
@@ -5566,14 +6008,64 @@ var MediaControllerRuntime = class {
5566
6008
  if (!track || this.#track("screen") !== track) return;
5567
6009
  this.#fork(this.#stopScreen().pipe(Effect5.ignore));
5568
6010
  }
6011
+ #observeProjections() {
6012
+ const ports = this.#ports;
6013
+ this.#watchMicrophone(ports?.media.getSnapshot().localTracks.find((item) => item.source === "microphone")?.track ?? this.#track("microphone") ?? null);
6014
+ if (!ports) {
6015
+ this.#projection.reset();
6016
+ return;
6017
+ }
6018
+ this.#enforcePause(ports, "microphone");
6019
+ this.#enforcePause(ports, "camera");
6020
+ if (!this.#syncLive) {
6021
+ this.#projection.reset();
6022
+ return;
6023
+ }
6024
+ this.#observeProjection(ports, "microphone");
6025
+ this.#observeProjection(ports, "camera");
6026
+ }
6027
+ #enforcePause(ports, source) {
6028
+ const publication = ports.media.getSnapshot().localTracks.find((item) => item.source === source);
6029
+ const state = this.#sources[source];
6030
+ if (!this.#hasConsent(source) && publication?.enabled && state.pausedPublicationId !== publication.publicationId) {
6031
+ this.#pauseTracks(source);
6032
+ ports.media.setLocalSourceIntent?.(source, false);
6033
+ this.#retainPause(state, this.#beginLocalPause(ports, source));
6034
+ }
6035
+ }
6036
+ #observeProjection(ports, source) {
6037
+ const sync = ports.sync.getSnapshot();
6038
+ const state = this.#sources[source];
6039
+ const publication = ports.media.getSnapshot().localTracks.find((item) => item.source === source);
6040
+ const projection = sync.media?.items.find((item) => item.participantId === sync.participantId && item.source === source) ?? { enabled: false, publicationId: null };
6041
+ this.#projection.observe(source, {
6042
+ revision: state.desired.revision,
6043
+ publicationId: publication?.publicationId ?? null,
6044
+ enabled: livePublication(publication),
6045
+ projectedEnabled: projection.enabled,
6046
+ projectedPublicationId: projection.publicationId
6047
+ });
6048
+ }
6049
+ #confirmedLocal(source) {
6050
+ const snapshot = this.#ports?.media.getSnapshot();
6051
+ if (snapshot?.connection.phase === "stopped") return { source, state: "unavailable", track: null };
6052
+ const publication = snapshot?.localTracks.find((item) => item.source === source);
6053
+ const sync = this.#ports?.sync.getSnapshot();
6054
+ const projected = sync?.media?.items.some((item) => item.participantId === sync.participantId && item.source === source && item.enabled && item.publicationId === publication?.publicationId) ?? false;
6055
+ const confirmed = this.#syncLive && livePublication(publication) && projected;
6056
+ const pending = this.#pendingSources.has(source) || this.#connection.getSnapshot().state === "joining";
6057
+ const track = publication?.track ?? this.#track(source) ?? null;
6058
+ return { source, track, state: confirmedLocalMediaState(track, this.#hasConsent(source), pending, confirmed, this.#syncLive, this.#projection.state(source)) };
6059
+ }
5569
6060
  #publish() {
5570
6061
  const current = this.#store.getSnapshot();
5571
6062
  const snapshot = this.#ports?.media.getSnapshot();
5572
6063
  const state = this.#connection.getSnapshot().state;
6064
+ this.#observeProjections();
5573
6065
  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)
6066
+ microphone: this.#confirmedLocal("microphone"),
6067
+ camera: this.#confirmedLocal("camera"),
6068
+ screen: screenLocalMedia(this.#track("screen") ?? null, snapshot, state, this.#pendingSources.has("screen") && this.#sources.screen.desired.enabled)
5577
6069
  });
5578
6070
  const remote = Object.freeze((snapshot?.remoteTracks ?? []).map((track) => Object.freeze({ participantId: track.participantId, source: track.source, publicationId: track.publicationId, track: track.track })));
5579
6071
  const media = Object.freeze({
@@ -5590,6 +6082,18 @@ var MediaControllerRuntime = class {
5590
6082
  if (!sameMediaSlice(current.media, media)) this.#store.updateMedia(media);
5591
6083
  }
5592
6084
  };
6085
+ function liveTrack(track) {
6086
+ return track.enabled && track.readyState === "live" && !track.muted;
6087
+ }
6088
+ function livePublication(publication) {
6089
+ return !!publication?.enabled && liveTrack(publication.track);
6090
+ }
6091
+ function confirmedLocalMediaState(track, desired, pending, confirmed, live, convergence) {
6092
+ if (!desired) return convergence === "failed" ? "failed" : "disabled";
6093
+ if (convergence) return convergence;
6094
+ if (!live && track && liveTrack(track)) return "requesting";
6095
+ return confirmed ? "enabled" : pending ? "requesting" : "disabled";
6096
+ }
5593
6097
  function cancelledCaptureError() {
5594
6098
  return new ConnectionError({ code: "invalid_state", action: null, recoverable: false, message: "Media capture was cancelled before its result could be used" });
5595
6099
  }
@@ -5598,57 +6102,35 @@ function foreign2(operation) {
5598
6102
  }
5599
6103
  function selectInitialTracks(stream, intent) {
5600
6104
  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();
6105
+ const selected = /* @__PURE__ */ new Map();
6106
+ for (const [source, kind] of [
6107
+ ["microphone", "audio"],
6108
+ ["camera", "video"]
6109
+ ]) {
6110
+ if (!intent[source]) continue;
6111
+ const track = tracks.find((track2) => track2.kind === kind);
6112
+ if (track) selected.set(source, track);
6113
+ }
6114
+ const retained = new Set(selected.values());
6115
+ for (const track of tracks) if (!retained.has(track)) track.stop();
6116
+ if (selected.size !== Number(intent.microphone) + Number(intent.camera)) {
6117
+ for (const track of retained) track.stop();
5607
6118
  throw new TypeError("Media capture did not return every requested track");
5608
6119
  }
5609
- return new Map(initialTrackEntries(microphone, camera));
6120
+ return selected;
5610
6121
  }
5611
- function initialTrackFor(tracks, enabled, kind) {
5612
- if (!enabled) return void 0;
5613
- return tracks.find((track) => track.kind === kind);
6122
+ function screenLocalMedia(track, snapshot, connectionState, pending) {
6123
+ if (snapshot?.connection.phase === "stopped") return Object.freeze({ source: "screen", state: "unavailable", track: null });
6124
+ const publication = snapshot?.localTracks.find((candidate) => candidate.source === "screen");
6125
+ return Object.freeze({ source: "screen", state: pending ? "requesting" : screenLocalMediaState(track, publication?.enabled, connectionState), track });
5614
6126
  }
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 });
5635
- }
5636
- function locallyMuted(intended, track, publication) {
5637
- return !intended && track?.enabled === false && publication?.enabled === false;
5638
- }
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";
6127
+ function screenLocalMediaState(track, enabled, connectionState) {
6128
+ if (enabled === false) {
6129
+ if (track?.enabled === false || connectionState === "live") return "disabled";
6130
+ }
6131
+ if (enabled) return "enabled";
6132
+ if (track) return "requesting";
6133
+ return connectionState === "live" || connectionState === "reconnecting" ? "disabled" : "unavailable";
5652
6134
  }
5653
6135
  function sameMediaSlice(left, right) {
5654
6136
  if (left.selection !== right.selection || !sameDevices(left.devices, right.devices)) return false;
@@ -6655,7 +7137,7 @@ var SpaceClientCore = class {
6655
7137
  return this.#connection.leave().pipe(Effect12.mapError(normalizeClientError));
6656
7138
  }
6657
7139
  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));
7140
+ return this.#connection.runCommand(({ sync }) => this.#connection.runEpisodeEnd(foreign6(() => sync.endEpisode()))).pipe(Effect12.tap(() => this.#connection.leave())).pipe(Effect12.asVoid, Effect12.mapError(normalizeClientError));
6659
7141
  }
6660
7142
  extendEpisode(minutes) {
6661
7143
  return this.#connection.runCommand(({ sync }) => foreign6(() => sync.extendEpisode(minutes))).pipe(Effect12.asVoid, Effect12.mapError(normalizeClientError));
@@ -6731,7 +7213,14 @@ function foreign6(operation) {
6731
7213
  }
6732
7214
  async function fetchAccessGrant2(input) {
6733
7215
  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 });
7216
+ return input.options.getAccess({
7217
+ space: input.options.space.trim(),
7218
+ reason: accessReason(input.request?.reason),
7219
+ replaceMediaConnection: input.request?.replaceMediaConnection ?? false,
7220
+ expectedMediaConnectionId: input.request?.expectedMediaConnectionId,
7221
+ currentMediaToken: input.request?.replaceMediaConnection ? input.request.currentMediaToken : void 0,
7222
+ signal: input.request?.signal
7223
+ });
6735
7224
  }
6736
7225
  function accessReason(reason) {
6737
7226
  if (reason === void 0 || reason === "join") return "join";