@q9labsai/chalk-client 5.2.3 → 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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/dist/access/grant.d.ts +2 -1
  3. package/dist/access/manager.d.ts +1 -0
  4. package/dist/{chunk-IMMT22US.js → chunk-5YPQNMJE.js} +11 -0
  5. package/dist/{chunk-4ZUN55Z2.js → chunk-BUNSEDXL.js} +840 -185
  6. package/dist/{chunk-DWKTGBHR.js → chunk-FD25AMLG.js} +322 -206
  7. package/dist/{chunk-6LNRDKJF.js → chunk-LKNT6XFD.js} +1 -1
  8. package/dist/{chunk-GA3JDPFI.js → chunk-UMAE5GCV.js} +205 -5
  9. package/dist/client.d.ts +131 -0
  10. package/dist/connection/dependencies.d.ts +10 -1
  11. package/dist/connection/diagnostics.d.ts +4 -0
  12. package/dist/connection/episode-status.d.ts +7 -0
  13. package/dist/connection/index.d.ts +2 -0
  14. package/dist/connection/lifecycle.d.ts +2 -0
  15. package/dist/connection/report.d.ts +21 -0
  16. package/dist/effect.js +30 -4
  17. package/dist/generated/http-api.d.ts +129 -0
  18. package/dist/generated/schemas.d.ts +219 -0
  19. package/dist/index.js +5 -5
  20. package/dist/media/client.d.ts +1 -1
  21. package/dist/media/failure-diagnostics.d.ts +10 -0
  22. package/dist/media/index.js +1 -1
  23. package/dist/media/plane.d.ts +1 -0
  24. package/dist/media/types.d.ts +5 -1
  25. package/dist/public-invites/index.js +2 -2
  26. package/dist/server/access-refresh.d.cts +1 -1
  27. package/dist/server/access-refresh.d.ts +1 -1
  28. package/dist/server/index.cjs +15 -5
  29. package/dist/server/index.js +15 -5
  30. package/dist/server/types.d.cts +2 -1
  31. package/dist/server/types.d.ts +2 -1
  32. package/dist/space-client/media-projection-monitor.d.ts +26 -0
  33. package/dist/space-client/types.d.ts +4 -0
  34. package/dist/sync/index.js +1 -1
  35. package/dist/sync/v1-types.d.ts +3 -0
  36. package/dist/transport-diagnostics.d.ts +3 -0
  37. package/package.json +2 -2
@@ -1,12 +1,16 @@
1
1
  import {
2
2
  CloudflareSFUClient,
3
+ CloudflareSFUError,
4
+ classifyMediaFailure,
3
5
  createCloudflareSFUHTTPTransport,
4
- describeMediaCaptureError
5
- } from "./chunk-DWKTGBHR.js";
6
+ describeMediaCaptureError,
7
+ grantedMediaFromStream,
8
+ markMediaFailure
9
+ } from "./chunk-FD25AMLG.js";
6
10
  import {
7
11
  AccessGrantError,
8
12
  requireParsedAccessGrant
9
- } from "./chunk-GA3JDPFI.js";
13
+ } from "./chunk-UMAE5GCV.js";
10
14
  import {
11
15
  ChatAttachmentMimeTypeSchema,
12
16
  ChatReactionSetFrameSchema,
@@ -20,22 +24,46 @@ import {
20
24
  formatSyncConnectDiagnostic,
21
25
  scheduleSyncDialDeadline,
22
26
  syncConnectFailureCode
23
- } from "./chunk-IMMT22US.js";
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
  );
@@ -59,9 +87,14 @@ var makeConnectionAccessLayer = (provider, refreshWindowMs = DEFAULT_REFRESH_WIN
59
87
  automaticMediaReplacementListeners.add(listener);
60
88
  return () => automaticMediaReplacementListeners.delete(listener);
61
89
  }),
62
- requiresRefresh: millisecondsUntilRefresh.pipe(Effect.map((milliseconds) => milliseconds === null || milliseconds === 0)),
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";
@@ -2156,7 +2205,7 @@ var ChalkWhiteboardV1Client = class {
2156
2205
  return this.#options.clock ?? {
2157
2206
  now: Date.now,
2158
2207
  monotonicNow: () => globalThis.performance.now(),
2159
- setTimeout: (callback, milliseconds) => globalThis.setTimeout(callback, milliseconds),
2208
+ setTimeout: (callback, milliseconds2) => globalThis.setTimeout(callback, milliseconds2),
2160
2209
  clearTimeout: (handle) => globalThis.clearTimeout(handle)
2161
2210
  };
2162
2211
  }
@@ -2288,6 +2337,162 @@ 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
+
2362
+ // src/connection/report.ts
2363
+ var MAX_EVENTS = 64;
2364
+ var MAX_BYTES = 32768;
2365
+ var MAX_MS = 2147483647;
2366
+ var COOLDOWN_MS = 6e4;
2367
+ var participantCooldowns = /* @__PURE__ */ new Map();
2368
+ var milliseconds = (value) => Math.max(0, Math.min(MAX_MS, Math.round(Number.isFinite(value) ? value : 0)));
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
+ }
2380
+ function reserveParticipantReport(access, now) {
2381
+ const key = `${access.subject.tenantId}:${access.subject.participantId}`;
2382
+ for (const [participant, until] of participantCooldowns) if (until <= now) participantCooldowns.delete(participant);
2383
+ if (participantCooldowns.has(key)) return false;
2384
+ if (participantCooldowns.size >= 128) participantCooldowns.delete(participantCooldowns.keys().next().value ?? "");
2385
+ participantCooldowns.set(key, now + COOLDOWN_MS);
2386
+ return true;
2387
+ }
2388
+ var ConnectionReporter = class {
2389
+ #events = [];
2390
+ #startedAt;
2391
+ #options;
2392
+ #pending = null;
2393
+ #unsubscribe;
2394
+ #controller = null;
2395
+ #retryOnReconnect = false;
2396
+ #disposed = false;
2397
+ constructor(options) {
2398
+ this.#options = options;
2399
+ this.#startedAt = options.now();
2400
+ }
2401
+ recordDiagnostic(diagnostic) {
2402
+ this.#record({
2403
+ event: diagnostic.event,
2404
+ elapsed_ms: this.#elapsed(),
2405
+ state: diagnostic.state,
2406
+ step: diagnostic.step,
2407
+ outcome: diagnostic.outcome,
2408
+ code: diagnostic.code,
2409
+ attempt: optionalMilliseconds(diagnostic.attempt),
2410
+ duration_ms: optionalMilliseconds(diagnostic.sync?.durationMs ?? diagnostic.durationMs),
2411
+ close_code: diagnostic.sync?.closeCode ?? void 0,
2412
+ heartbeat_gap_ms: optionalMilliseconds(diagnostic.recovery?.lastHeartbeatReplyAgoMs),
2413
+ ...mediaReportFields(diagnostic.media)
2414
+ });
2415
+ if (diagnostic.event === "recovery_exhausted") this.#report("recovery_exhausted");
2416
+ else if (diagnostic.event === "connection_failed") this.#report(diagnostic.state === "joining" ? "join_failed" : "connection_lost");
2417
+ }
2418
+ recordTransport(event) {
2419
+ this.#record({
2420
+ event: event.event,
2421
+ elapsed_ms: this.#elapsed(),
2422
+ ...event.phase ? { phase: event.phase } : {},
2423
+ ...event.attempt == null ? {} : { attempt: milliseconds(event.attempt) },
2424
+ ...event.duration_ms == null ? {} : { duration_ms: milliseconds(event.duration_ms) },
2425
+ ...event.heartbeat_gap_ms == null ? {} : { heartbeat_gap_ms: milliseconds(event.heartbeat_gap_ms) },
2426
+ ...event.close_code == null ? {} : { close_code: event.close_code },
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
2432
+ });
2433
+ if (event.event === "media_projection_mismatch") this.#report("media_projection_mismatch");
2434
+ }
2435
+ dispose() {
2436
+ this.#disposed = true;
2437
+ this.#controller?.abort();
2438
+ this.#stopListening();
2439
+ this.#pending = null;
2440
+ this.#events.length = 0;
2441
+ }
2442
+ get traceparent() {
2443
+ return this.#options.traceparent;
2444
+ }
2445
+ #elapsed() {
2446
+ return milliseconds(this.#options.now() - this.#startedAt);
2447
+ }
2448
+ #record(event) {
2449
+ if (this.#disposed) return;
2450
+ this.#events.push(event);
2451
+ if (this.#events.length > MAX_EVENTS) this.#events.shift();
2452
+ }
2453
+ #report(trigger) {
2454
+ if (this.#disposed || this.#pending) return;
2455
+ const access = this.#options.access();
2456
+ if (!access) return;
2457
+ if (!reserveParticipantReport(access, this.#options.now())) return;
2458
+ const events = this.#events.slice();
2459
+ const report = { version: 1, trigger, events };
2460
+ while (events.length && new TextEncoder().encode(JSON.stringify(report)).byteLength > MAX_BYTES) events.shift();
2461
+ if (!events.length) return;
2462
+ this.#pending = { access, report, attempts: 0 };
2463
+ this.#unsubscribe = this.#options.subscribeOnline(() => {
2464
+ this.#retryOnReconnect = true;
2465
+ void this.#flush();
2466
+ });
2467
+ void this.#flush();
2468
+ }
2469
+ async #flush() {
2470
+ const pending = this.#pending;
2471
+ if (!pending || this.#controller || this.#disposed || !this.#options.online()) return;
2472
+ this.#retryOnReconnect = false;
2473
+ const controller = new AbortController();
2474
+ this.#controller = controller;
2475
+ pending.attempts += 1;
2476
+ const timer = globalThis.setTimeout(() => controller.abort(), 5e3);
2477
+ try {
2478
+ await this.#options.send(pending.access, pending.report, controller.signal, this.traceparent);
2479
+ this.#pending = null;
2480
+ } catch {
2481
+ if (pending.attempts >= 2) this.#pending = null;
2482
+ } finally {
2483
+ globalThis.clearTimeout(timer);
2484
+ this.#controller = null;
2485
+ if (!this.#pending) this.#stopListening();
2486
+ if (this.#retryOnReconnect) void this.#flush();
2487
+ }
2488
+ }
2489
+ #stopListening() {
2490
+ this.#unsubscribe?.();
2491
+ this.#unsubscribe = void 0;
2492
+ this.#retryOnReconnect = false;
2493
+ }
2494
+ };
2495
+
2291
2496
  // src/connection/types.ts
2292
2497
  var ConnectionError = class extends Error {
2293
2498
  code;
@@ -2309,7 +2514,12 @@ function createBrowserMediaDevices(mediaDevices = globalThis.navigator?.mediaDev
2309
2514
  return {
2310
2515
  async getUserMedia(constraints) {
2311
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." });
2312
- return mediaDevices.getUserMedia(constraints);
2517
+ try {
2518
+ return await mediaDevices.getUserMedia(constraints);
2519
+ } catch (error2) {
2520
+ markMediaFailure(error2, "local_capture");
2521
+ throw error2;
2522
+ }
2313
2523
  },
2314
2524
  async getDisplayMedia(constraints) {
2315
2525
  if (!mediaDevices?.getDisplayMedia) throw new DOMException("Browser display capture is unavailable", "NotSupportedError");
@@ -2359,10 +2569,49 @@ function createDefaultConnectionDependencies(options) {
2359
2569
  return {
2360
2570
  clock: {
2361
2571
  now: () => Date.now(),
2362
- setTimeout: (callback, milliseconds) => globalThis.setTimeout(callback, milliseconds),
2572
+ setTimeout: (callback, milliseconds2) => globalThis.setTimeout(callback, milliseconds2),
2363
2573
  clearTimeout: (handle) => globalThis.clearTimeout(handle)
2364
2574
  },
2575
+ createConnectionReporter: (access) => {
2576
+ const random = globalThis.crypto;
2577
+ if (!random?.randomUUID) return void 0;
2578
+ return new ConnectionReporter({
2579
+ traceparent: `00-${random.randomUUID().replaceAll("-", "")}-${random.randomUUID().replaceAll("-", "").slice(0, 16)}-01`,
2580
+ now: () => performance.now(),
2581
+ access,
2582
+ online: () => globalThis.navigator?.onLine !== false,
2583
+ subscribeOnline: (listener) => {
2584
+ globalThis.window?.addEventListener("online", listener);
2585
+ return () => globalThis.window?.removeEventListener("online", listener);
2586
+ },
2587
+ send: async (grant, report, signal, traceparent) => {
2588
+ const response = await fetch(`${options.apiBaseURL.replace(/\/$/, "")}/v1/tenants/${encodeURIComponent(grant.subject.tenantId)}/connection-reports`, {
2589
+ method: "POST",
2590
+ headers: { Authorization: `Bearer ${grant.media.token}`, "Content-Type": "application/json", traceparent },
2591
+ body: JSON.stringify(report),
2592
+ signal,
2593
+ keepalive: true,
2594
+ credentials: "omit"
2595
+ });
2596
+ await response.body?.cancel();
2597
+ }
2598
+ });
2599
+ },
2365
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
+ },
2366
2615
  cancelPendingAdmission: async (access, signal) => {
2367
2616
  const { tenantId, spaceId, episodeId, participantId } = access.subject;
2368
2617
  const path = [tenantId, spaceId, episodeId, participantId].map(encodeURIComponent);
@@ -2426,6 +2675,8 @@ function createMediaClient(apiBaseURL, input) {
2426
2675
  transport: createCloudflareSFUHTTPTransport({
2427
2676
  apiBaseURL,
2428
2677
  credential: input.credential,
2678
+ onDiagnostic: input.onTransportDiagnostic,
2679
+ traceparent: input.traceparent,
2429
2680
  tenantId: subject.tenantId,
2430
2681
  spaceId: subject.spaceId,
2431
2682
  episodeId: subject.episodeId,
@@ -2467,6 +2718,8 @@ function bindConnectionMediaClient(client, restart) {
2467
2718
  function createSyncClient(syncURL, input) {
2468
2719
  return createV1SyncClient({
2469
2720
  url: syncURL,
2721
+ onDiagnostic: input.onTransportDiagnostic,
2722
+ traceparent: input.traceparent,
2470
2723
  token: input.token,
2471
2724
  mediaPlane: input.media,
2472
2725
  collaboration: { name: "collaboration_v2", chatCursor: { afterSequence: null, retainedFloorSequence: null } },
@@ -2498,7 +2751,7 @@ function whiteboardURL(syncURL) {
2498
2751
  }
2499
2752
 
2500
2753
  // src/foreground-deadline.ts
2501
- function scheduleForegroundDeadline(clock, milliseconds, expired) {
2754
+ function scheduleForegroundDeadline(clock, milliseconds2, expired) {
2502
2755
  let timer;
2503
2756
  let cancelled = false;
2504
2757
  const arm = () => {
@@ -2506,9 +2759,9 @@ function scheduleForegroundDeadline(clock, milliseconds, expired) {
2506
2759
  timer = clock.setTimeout(() => {
2507
2760
  if (cancelled) return;
2508
2761
  const elapsed = clock.now() - started;
2509
- if (typeof document !== "undefined" && document.visibilityState === "hidden" || elapsed > milliseconds + 2e3 || elapsed < 0) arm();
2762
+ if (typeof document !== "undefined" && document.visibilityState === "hidden" || elapsed > milliseconds2 + 2e3 || elapsed < 0) arm();
2510
2763
  else expired();
2511
- }, milliseconds);
2764
+ }, milliseconds2);
2512
2765
  };
2513
2766
  arm();
2514
2767
  return () => {
@@ -2518,7 +2771,7 @@ function scheduleForegroundDeadline(clock, milliseconds, expired) {
2518
2771
  }
2519
2772
 
2520
2773
  // src/connection/lifecycle.ts
2521
- 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";
2522
2775
 
2523
2776
  // src/connection/diagnostics.ts
2524
2777
  var ConnectionDiagnostics = class {
@@ -2543,11 +2796,17 @@ var ConnectionDiagnostics = class {
2543
2796
  }
2544
2797
  recordRecoveryExhausted(input) {
2545
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);
2546
2804
  this.record({
2547
2805
  event: "recovery_exhausted",
2548
2806
  state,
2549
2807
  epoch,
2550
2808
  code,
2809
+ ...media ? { media } : {},
2551
2810
  recovery: {
2552
2811
  reference: `connection.${code}`,
2553
2812
  attempts,
@@ -2579,7 +2838,8 @@ var ConnectionDiagnostics = class {
2579
2838
  ...input.parentSpanId ? { parentSpanId: input.parentSpanId } : {},
2580
2839
  outcome: end.outcome,
2581
2840
  durationMs: Math.max(0, this.#now() - startedAt),
2582
- ...end.code ? { code: end.code } : {}
2841
+ ...end.code ? { code: end.code } : {},
2842
+ ...end.media ? { media: end.media } : {}
2583
2843
  });
2584
2844
  }
2585
2845
  };
@@ -2593,7 +2853,7 @@ var ConnectionDiagnostics = class {
2593
2853
  var MEDIA_RECOVERY_TIMEOUT_MS = 6e4;
2594
2854
  var START_TIMEOUT_MS = 3e4;
2595
2855
  var LEAVE_TIMEOUT_MS = 5e3;
2596
- var RECOVERY_BUDGET_MS = 1e4;
2856
+ var RECOVERY_BUDGET_MS = MEDIA_RECOVERY_TIMEOUT_MS;
2597
2857
  var MAX_RECOVERY_ATTEMPTS = 3;
2598
2858
  var REFRESH_RETRY_MS = 5e3;
2599
2859
  var RECONNECT_GRACE_MS = 250;
@@ -2634,7 +2894,18 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2634
2894
  const clock = yield* Clock2.Clock;
2635
2895
  const context = yield* Effect2.context();
2636
2896
  const serviceScope = yield* Effect2.scope;
2637
- const diagnostics = new ConnectionDiagnostics({ now: () => clock.currentTimeMillisUnsafe(), ...options.diagnostics });
2897
+ const reporter = platform.createConnectionReporter?.(access.currentUnsafe);
2898
+ let grantedMedia = { granted_audio: "unknown", granted_video: "unknown" };
2899
+ let mediaFailure;
2900
+ const diagnostics = new ConnectionDiagnostics({
2901
+ now: () => clock.currentTimeMillisUnsafe(),
2902
+ ...options.diagnostics,
2903
+ onEvent: (event) => {
2904
+ reporter?.recordDiagnostic(event);
2905
+ options.diagnostics?.onEvent?.(event);
2906
+ }
2907
+ });
2908
+ const onTransportDiagnostic = (event) => reporter?.recordTransport(event);
2638
2909
  const snapshotRef = yield* SubscriptionRef.make(idleSnapshot());
2639
2910
  const queue = yield* Queue.unbounded();
2640
2911
  const listeners = /* @__PURE__ */ new Set();
@@ -2645,6 +2916,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2645
2916
  let fallbackIdentifier = 0;
2646
2917
  let emittedPorts = null;
2647
2918
  let foregroundRecoveryDeadline = 0;
2919
+ let pendingEpisodeEnd = null;
2920
+ let rejectionCheck = null;
2648
2921
  const model = {
2649
2922
  state: "idle",
2650
2923
  sync: null,
@@ -2676,11 +2949,11 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2676
2949
  epoch: 0
2677
2950
  };
2678
2951
  const mediaAttempt = { expiresAt: null };
2679
- 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) });
2680
- const withinForegroundBudget = (effect, milliseconds, failure2) => Effect2.raceFirst(
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) });
2953
+ const withinForegroundBudget = (effect, milliseconds2, failure2) => Effect2.raceFirst(
2681
2954
  effect,
2682
2955
  Effect2.callback((resume) => {
2683
- const cancel = scheduleForegroundDeadline(platform.clock, milliseconds, () => resume(Effect2.fail(typeof failure2 === "function" ? failure2() : failure2)));
2956
+ const cancel = scheduleForegroundDeadline(platform.clock, milliseconds2, () => resume(Effect2.fail(typeof failure2 === "function" ? failure2() : failure2)));
2684
2957
  return Effect2.sync(cancel);
2685
2958
  })
2686
2959
  );
@@ -2726,9 +2999,9 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2726
2999
  });
2727
3000
  const enqueue = (effect, join = false) => Effect2.gen(function* () {
2728
3001
  if (model.closed) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "The Connection scope is closed"));
2729
- const deferred = yield* Deferred.make();
3002
+ const deferred = yield* Deferred2.make();
2730
3003
  yield* Queue.offer(queue, { effect, deferred, join });
2731
- return yield* Deferred.await(deferred);
3004
+ return yield* Deferred2.await(deferred);
2732
3005
  });
2733
3006
  const enqueueBackground = (effect) => {
2734
3007
  void Effect2.runForkWith(context)(enqueue(effect).pipe(Effect2.ignore));
@@ -2741,10 +3014,10 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2741
3014
  const scheduleRefresh = (delay) => Effect2.gen(function* () {
2742
3015
  yield* stopRefresh();
2743
3016
  if (!active(model)) return;
2744
- const milliseconds = delay ?? (yield* access.millisecondsUntilRefresh) ?? 0;
3017
+ const milliseconds2 = delay ?? (yield* access.millisecondsUntilRefresh) ?? 0;
2745
3018
  const scope = model.activeScope;
2746
3019
  if (!scope) return;
2747
- model.refreshFiber = yield* Effect2.forkIn(Effect2.sleep(Math.max(0, milliseconds)).pipe(Effect2.andThen(enqueue(refreshAccess())), Effect2.ignore), scope);
3020
+ model.refreshFiber = yield* Effect2.forkIn(Effect2.sleep(Math.max(0, milliseconds2)).pipe(Effect2.andThen(enqueue(refreshAccess())), Effect2.ignore), scope);
2748
3021
  });
2749
3022
  const refreshAccess = () => Effect2.gen(function* () {
2750
3023
  const scope = model.activeScope;
@@ -2884,7 +3157,13 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2884
3157
  const span = diagnostics.startSpan({ step, state: model.state, epoch: model.epoch });
2885
3158
  return effect.pipe(
2886
3159
  Effect2.tap(() => Effect2.sync(() => span.end({ state: model.state, epoch: model.epoch, outcome: "succeeded" }))),
2887
- 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
+ )
2888
3167
  );
2889
3168
  };
2890
3169
  const confirmSyncEntry = (sync) => {
@@ -2895,6 +3174,10 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2895
3174
  if (model.state === "live") return;
2896
3175
  if (model.state === "leaving" || model.closed) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Cannot join an inactive Connection"));
2897
3176
  model.epoch += 1;
3177
+ pendingEpisodeEnd = null;
3178
+ rejectionCheck = null;
3179
+ grantedMedia = { granted_audio: "unknown", granted_video: "unknown" };
3180
+ mediaFailure = void 0;
2898
3181
  model.failure = null;
2899
3182
  model.episodeEndConfirmed = false;
2900
3183
  model.episodeEndReason = void 0;
@@ -2909,6 +3192,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2909
3192
  if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
2910
3193
  const captured = yield* trace("acquire_initial_media", acquireInitialMedia(model.initialMedia, model.initialMediaIntent?.() ?? model.intent));
2911
3194
  stream = captured.stream;
3195
+ grantedMedia = grantedMediaFromStream(stream);
2912
3196
  captureWarning = captured.failure;
2913
3197
  if (model.joinCancelled) return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
2914
3198
  const grant = yield* trace("access_initialize", reason === "access_retry" ? access.refreshAfterRejection() : access.initialize(reason));
@@ -2923,7 +3207,9 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2923
3207
  if (media === model.media) mediaAttempt.expiresAt = platform.clock.now() + MEDIA_RECOVERY_TIMEOUT_MS;
2924
3208
  return replacement.media;
2925
3209
  }),
2926
- onFailure: () => enqueueBackground(handleMediaFailure()),
3210
+ onTransportDiagnostic,
3211
+ traceparent: reporter?.traceparent,
3212
+ onFailure: (cause, binding = grant.media.clientPayload.connectionId) => enqueueBackground(handleMediaFailure(media, cause, binding)),
2927
3213
  onScreenEnded: () => enqueueBackground(notifyScreenEnded())
2928
3214
  }),
2929
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)
@@ -2933,7 +3219,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2933
3219
  const sync = yield* trace(
2934
3220
  "create_sync_client",
2935
3221
  Effect2.try({
2936
- try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media }),
3222
+ try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media, onTransportDiagnostic, traceparent: reporter?.traceparent }),
2937
3223
  catch: (cause) => accessRejected(cause) ? lifecycleFailure("invalid_access", false, "Access was rejected", cause) : lifecycleFailure("sync_start_failed", true, "The sync layer could not start", cause)
2938
3224
  })
2939
3225
  );
@@ -2943,7 +3229,11 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2943
3229
  [
2944
3230
  trace(
2945
3231
  "start_media",
2946
- 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
+ )
2947
3237
  ),
2948
3238
  trace(
2949
3239
  "start_sync",
@@ -2961,14 +3251,20 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2961
3251
  Effect2.matchEffect({
2962
3252
  onFailure: (cause) => Effect2.gen(function* () {
2963
3253
  stopStream(stream);
2964
- 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
+ }
2965
3261
  model.failure = toFailure(failure2);
2966
3262
  if (model.joinCancelled) {
2967
3263
  span.end({ state: model.state, epoch: model.epoch, outcome: "cancelled", code: "invalid_state" });
2968
3264
  return yield* Effect2.fail(lifecycleFailure("invalid_state", false, "Join was cancelled by Leave"));
2969
3265
  }
2970
3266
  span.end({ state: model.state, epoch: model.epoch, outcome: "failed", code: failure2.code });
2971
- const retry = !retried && failure2.code === "invalid_access";
3267
+ const retry = !retried && failure2.code === "invalid_access" && !sfuAccessRejected(failure2.cause);
2972
3268
  if (!retry) yield* recordFailure(failure2);
2973
3269
  model.admissionCleanupConfirmed = yield* cancelPendingAdmission();
2974
3270
  yield* stopPorts();
@@ -3113,7 +3409,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3113
3409
  }
3114
3410
  const code = plan.kind === "media" ? "media_recovery_exhausted" : "sync_recovery_exhausted";
3115
3411
  const transport = model.sync?.getSnapshot().connection.recoveryDiagnostic ?? null;
3116
- diagnostics.recordRecoveryExhausted({ state: model.state, epoch, code, attempts, transport });
3412
+ diagnostics.recordRecoveryExhausted({ state: model.state, epoch, code, attempts, transport, media: mediaFailure, grantedMedia });
3117
3413
  if (plan.kind === "sync") {
3118
3414
  yield* failForSnapshot(lifecycleFailure(code, true, "Connection lost. Rejoin to continue."));
3119
3415
  return;
@@ -3135,12 +3431,12 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3135
3431
  Effect2.map((now) => ({
3136
3432
  kind,
3137
3433
  deadline: now + boundedInteger(options.recovery?.budgetMs, RECOVERY_BUDGET_MS, 1, 6e4),
3138
- attempts: boundedInteger(options.recovery?.maxAttempts, MAX_RECOVERY_ATTEMPTS, 1, 10),
3139
- delays: options.recovery?.backoffMs?.length ? options.recovery.backoffMs : [100, 250, 500]
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]
3140
3436
  }))
3141
3437
  );
3142
3438
  const attemptRecovery = (plan, mediaAttempt2, syncAttempt, onAttempt) => Effect2.gen(function* () {
3143
- for (let attempt = 1; attempt <= plan.attempts; attempt += 1) {
3439
+ for (let attempt = 1; plan.attempts === null || attempt <= plan.attempts; attempt += 1) {
3144
3440
  const remaining = plan.deadline - (yield* Clock2.currentTimeMillis);
3145
3441
  if (remaining <= 0) return null;
3146
3442
  onAttempt();
@@ -3151,7 +3447,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3151
3447
  );
3152
3448
  if (failure2 === null) return attempt;
3153
3449
  if (!failure2.recoverable) return yield* Effect2.fail(failure2);
3154
- if (attempt < plan.attempts) {
3450
+ if (plan.attempts === null || attempt < plan.attempts) {
3155
3451
  const remaining2 = Math.max(0, plan.deadline - (yield* Clock2.currentTimeMillis));
3156
3452
  yield* Effect2.sleep(Math.min(recoveryDelay(plan, attempt), remaining2));
3157
3453
  }
@@ -3168,10 +3464,13 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3168
3464
  });
3169
3465
  const recoveryDelay = (plan, attempt) => {
3170
3466
  const delay = plan.delays[Math.min(attempt - 1, plan.delays.length - 1)] ?? 0;
3171
- 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;
3172
3470
  };
3173
3471
  const finishRecovery = (attempt, epoch) => Effect2.gen(function* () {
3174
3472
  if (model.epoch !== epoch || !active(model)) return;
3473
+ mediaFailure = void 0;
3175
3474
  model.recoveryQueued = null;
3176
3475
  model.syncSnapshot = model.sync?.getSnapshot() ?? null;
3177
3476
  model.mediaSnapshot = model.media?.getSnapshot() ?? null;
@@ -3231,7 +3530,20 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3231
3530
  attempt.expiresAt = (yield* Clock2.currentTimeMillis) + MEDIA_RECOVERY_TIMEOUT_MS;
3232
3531
  yield* foreign7(() => media.restart(restartInput), "media_start_failed", "Media transport could not restart");
3233
3532
  yield* waitForMediaLive(media);
3234
- });
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
+ );
3235
3547
  const syncAttemptExpired = (attempt, sync, now) => {
3236
3548
  if (attempt.client !== sync) {
3237
3549
  attempt.client = sync;
@@ -3273,7 +3585,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3273
3585
  model.syncBindingCleanup?.();
3274
3586
  model.sync = null;
3275
3587
  const sync2 = yield* Effect2.try({
3276
- try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media }),
3588
+ try: () => platform.createSyncClient({ access: grant, token: () => toPromise(access.getSyncToken()), media, onTransportDiagnostic, traceparent: reporter?.traceparent }),
3277
3589
  catch: (cause) => lifecycleFailure("sync_start_failed", true, "The sync layer could not start", cause)
3278
3590
  });
3279
3591
  model.sync = sync2;
@@ -3341,23 +3653,128 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3341
3653
  yield* transition("left");
3342
3654
  });
3343
3655
  const syncNeedsRecovery = (snapshot) => active(model) && (snapshot.connection.phase === "connecting" || snapshot.connection.phase === "recovering" || snapshot.connection.phase === "terminal");
3656
+ const mediaNeedsRecovery = (snapshot) => active(model) && (snapshot.connection.phase === "recovering" || snapshot.connection.phase === "failed" && snapshot.failure?.recoverable === true);
3344
3657
  const recordFailure = (failure2) => Effect2.sync(() => {
3345
- 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 } : {} });
3346
3659
  });
3347
- 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();
3348
3715
  model.failure = toFailure(failure2);
3349
3716
  yield* recordFailure(failure2);
3350
3717
  yield* stopPorts();
3351
3718
  yield* transition("failed");
3352
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
+ };
3353
3763
  const handleMediaSnapshot = (media, snapshot) => Effect2.gen(function* () {
3354
3764
  if (media !== model.media || media.getSnapshot() !== snapshot) return;
3355
3765
  if (snapshot === model.mediaSnapshot) return;
3766
+ const phase = mediaDiagnosticPhase(snapshot);
3767
+ if (phase !== mediaDiagnosticPhase(model.mediaSnapshot)) onTransportDiagnostic({ event: "media_state", phase });
3356
3768
  model.mediaSnapshot = snapshot;
3357
- if (active(model) && (snapshot.connection.phase === "recovering" || snapshot.connection.phase === "failed" && snapshot.failure?.recoverable)) yield* recover("media");
3769
+ if (mediaNeedsRecovery(snapshot)) yield* recover("media");
3358
3770
  yield* publish();
3359
3771
  });
3360
- 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
+ };
3361
3778
  const applyAutomaticMediaReplacement = (grant) => Effect2.gen(function* () {
3362
3779
  const media = model.media;
3363
3780
  const scope = model.activeScope;
@@ -3373,7 +3790,12 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3373
3790
  "media_start_failed",
3374
3791
  "The media client could not apply a replacement access grant"
3375
3792
  ).pipe(
3376
- (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
+ ),
3377
3799
  Effect2.match({ onSuccess: () => true, onFailure: () => false }),
3378
3800
  Effect2.flatMap(
3379
3801
  (restarted) => enqueue(
@@ -3407,8 +3829,8 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3407
3829
  const exit = yield* Effect2.exit(Fiber.join(fiber));
3408
3830
  activeJoin = null;
3409
3831
  joinDeferred = null;
3410
- yield* Deferred.done(work.deferred, exit);
3411
- }) : 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);
3412
3834
  yield* Effect2.forkScoped(Queue.take(queue).pipe(Effect2.flatMap(process), Effect2.forever));
3413
3835
  const accessReplacementUnsubscribe = yield* access.subscribeAutomaticMediaReplacement((grant) => enqueueBackground(applyAutomaticMediaReplacement(grant)));
3414
3836
  const foregroundUnsubscribe = platform.subscribeForeground?.(() => {
@@ -3418,6 +3840,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3418
3840
  yield* Effect2.addFinalizer(
3419
3841
  () => Effect2.gen(function* () {
3420
3842
  model.closed = true;
3843
+ reporter?.dispose();
3421
3844
  accessReplacementUnsubscribe();
3422
3845
  foregroundUnsubscribe?.();
3423
3846
  if (activeJoin) yield* Fiber.interrupt(activeJoin);
@@ -3457,15 +3880,33 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3457
3880
  confirmEpisodeEnded: Effect2.sync(() => {
3458
3881
  model.episodeEndConfirmed = true;
3459
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
+ }),
3460
3901
  join: () => Effect2.suspend(() => {
3461
3902
  if (model.state === "live") return Effect2.void;
3462
- if (joinDeferred) return Deferred.await(joinDeferred);
3903
+ if (joinDeferred) return Deferred2.await(joinDeferred);
3463
3904
  return Effect2.gen(function* () {
3464
- const deferred = yield* Deferred.make();
3905
+ const deferred = yield* Deferred2.make();
3465
3906
  model.joinCancelled = false;
3466
3907
  joinDeferred = deferred;
3467
3908
  yield* Queue.offer(queue, { effect: performJoin(), deferred, join: true });
3468
- return yield* Deferred.await(deferred);
3909
+ return yield* Deferred2.await(deferred);
3469
3910
  });
3470
3911
  }),
3471
3912
  leave: () => Effect2.gen(function* () {
@@ -3491,9 +3932,10 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
3491
3932
  ),
3492
3933
  runPortCommand: (operation) => withFreshAccess(operation),
3493
3934
  nowUnsafe: () => clock.currentTimeMillisUnsafe(),
3494
- scheduleUnsafe: (callback, milliseconds) => platform.clock.setTimeout(callback, milliseconds),
3935
+ scheduleUnsafe: (callback, milliseconds2) => platform.clock.setTimeout(callback, milliseconds2),
3495
3936
  cancelScheduleUnsafe: (handle) => platform.clock.clearTimeout(handle),
3496
- createId: () => platform.createId?.() ?? `local-${++fallbackIdentifier}`
3937
+ createId: () => platform.createId?.() ?? `local-${++fallbackIdentifier}`,
3938
+ recordTransportDiagnostic: onTransportDiagnostic
3497
3939
  };
3498
3940
  })
3499
3941
  );
@@ -3538,7 +3980,12 @@ function toFailure(value) {
3538
3980
  return Object.freeze({ code: value.code, action: null, recoverable: value.recoverable, message: value.message, ...value.sync ? { sync: value.sync } : {} });
3539
3981
  }
3540
3982
  function accessFailure(value) {
3541
- 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";
3542
3989
  }
3543
3990
  function captureFailure(cause) {
3544
3991
  if (cause instanceof ConnectionError && cause.cause === void 0) return lifecycleFailure(cause.code, cause.recoverable, cause.message, cause);
@@ -3594,15 +4041,19 @@ function initialCaptureWarning(failures) {
3594
4041
  return lifecycleFailure(first.failure.code, true, message, first.failure.cause);
3595
4042
  }
3596
4043
  function failureFromCause(cause, code, message) {
4044
+ if (sfuAccessRejected(cause)) return lifecycleFailure("invalid_access", false, "Media access was rejected. Rejoin explicitly to continue.", cause);
3597
4045
  if (cause instanceof ConnectionLifecycleFailure) return cause;
3598
4046
  if (cause instanceof ConnectionAccessFailure) return accessFailure(cause);
3599
- 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 }));
3600
4048
  if (cause instanceof ConnectionError) return lifecycleFailure(cause.code, cause.recoverable, cause.message, cause);
3601
4049
  if (cause instanceof DOMException && cause.name === "NotAllowedError") return lifecycleFailure("permission_denied", true, "Media permission was denied", cause);
3602
4050
  return lifecycleFailure(code, true, message, cause);
3603
4051
  }
4052
+ function sfuAccessRejected(cause) {
4053
+ return cause instanceof CloudflareSFUError && (cause.options.status === 401 || cause.options.status === 403);
4054
+ }
3604
4055
  function accessRejected(cause) {
3605
- 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");
3606
4057
  }
3607
4058
  function boundedInteger(value, fallback, minimum, maximum) {
3608
4059
  const result = value ?? fallback;
@@ -3617,6 +4068,9 @@ function syncPhase(phase) {
3617
4068
  if (phase === "connecting") return "connecting";
3618
4069
  return "idle";
3619
4070
  }
4071
+ function mediaDiagnosticPhase(snapshot) {
4072
+ return snapshot?.connection.peerConnectionState ?? snapshot?.connection.phase;
4073
+ }
3620
4074
  function mediaPhase(phase) {
3621
4075
  if (phase === "live") return "healthy";
3622
4076
  if (phase === "recovering") return "recovering";
@@ -3629,6 +4083,7 @@ function syncTerminalFailure(snapshot) {
3629
4083
  const reason = snapshot.connection.terminalReason;
3630
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");
3631
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.");
3632
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");
3633
4088
  if (reason === "entry_timeout") return lifecycleFailure("sync_start_failed", true, "Call entry was not confirmed in time");
3634
4089
  return lifecycleFailure("sync_start_failed", true, "The Sync transport needs recovery");
@@ -3643,6 +4098,22 @@ function syncStartupFailure(snapshot) {
3643
4098
  function syncAutoRecoverable(snapshot, failure2) {
3644
4099
  return failure2.recoverable && snapshot.connection.terminalReason !== "entry_timeout";
3645
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
+ }
3646
4117
 
3647
4118
  // src/space-client/errors.ts
3648
4119
  import { Data as Data3 } from "effect";
@@ -3743,7 +4214,7 @@ function connectionErrorCode(code, action) {
3743
4214
 
3744
4215
  // src/space-client/store.ts
3745
4216
  import { Context as Context4, Effect as Effect3, Layer as Layer4, SubscriptionRef as SubscriptionRef2 } from "effect";
3746
- var INTERRUPTED_CONNECTION_PHASES = /* @__PURE__ */ new Set(["unresponsive", "connecting", "recovering"]);
4217
+ var INTERRUPTED_CONNECTION_PHASES = /* @__PURE__ */ new Set(["unresponsive", "connecting", "recovering", "stopped"]);
3747
4218
  var empty = (value) => Object.freeze(value);
3748
4219
  var EMPTY = empty({
3749
4220
  connection: empty({ status: "idle", episode: null, lastError: null, episodeEnded: false, removedByHost: false }),
@@ -4406,11 +4877,79 @@ function chatError(message) {
4406
4877
  }
4407
4878
 
4408
4879
  // src/space-client/media-controller.ts
4409
- 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
4410
4949
  var MEDIA_SOURCES = ["microphone", "camera", "screen"];
4411
4950
  var MEDIA_READY_TIMEOUT_MS = 1e4;
4412
4951
  function sourceWorker(enabled) {
4413
- 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 };
4414
4953
  }
4415
4954
  var MediaControllerService = class extends Context6.Service()("@chalk/client/MediaController") {
4416
4955
  };
@@ -4453,6 +4992,7 @@ var MediaControllerRuntime = class {
4453
4992
  #tracks = /* @__PURE__ */ new Map();
4454
4993
  #sources = { microphone: sourceWorker(true), camera: sourceWorker(true), screen: sourceWorker(false) };
4455
4994
  #syncLive = false;
4995
+ #recovering = null;
4456
4996
  #pendingSources = /* @__PURE__ */ new Map();
4457
4997
  #pendingCaptures = /* @__PURE__ */ new Set();
4458
4998
  #readinessChecks = /* @__PURE__ */ new Set();
@@ -4468,6 +5008,8 @@ var MediaControllerRuntime = class {
4468
5008
  #unsubscribeConnection = null;
4469
5009
  #unsubscribeScreenEnded = null;
4470
5010
  #unsubscribePorts = null;
5011
+ #microphoneWatch = null;
5012
+ #projection;
4471
5013
  constructor(connection, store, selection, gates, now, fork) {
4472
5014
  this.#connection = connection;
4473
5015
  this.#store = store;
@@ -4475,6 +5017,16 @@ var MediaControllerRuntime = class {
4475
5017
  this.#gates = gates;
4476
5018
  this.#now = now;
4477
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
+ });
4478
5030
  this.#unsubscribeConnection = connection.subscribe(() => this.#handleConnectionChange());
4479
5031
  this.#unsubscribeScreenEnded = connection.subscribeScreenEnded(() => this.#handleScreenEnded());
4480
5032
  this.#unsubscribePorts = connection.subscribePorts((ports) => this.#bind(ports));
@@ -4522,7 +5074,7 @@ var MediaControllerRuntime = class {
4522
5074
  }).pipe(Effect5.mapError(normalizeMediaError));
4523
5075
  };
4524
5076
  initialIntent() {
4525
- return { microphone: this.#sources.microphone.desired.enabled, camera: this.#sources.camera.desired.enabled };
5077
+ return { microphone: this.#hasConsent("microphone"), camera: this.#hasConsent("camera") };
4526
5078
  }
4527
5079
  captureInitial(intent) {
4528
5080
  for (const source of ["microphone", "camera"]) this.#sources[source].initialRevision = this.#sources[source].desired.revision;
@@ -4578,6 +5130,7 @@ var MediaControllerRuntime = class {
4578
5130
  this.#fork(this.#refreshDevices().pipe(Effect5.ignore));
4579
5131
  }
4580
5132
  dispose() {
5133
+ this.#projection.reset();
4581
5134
  for (const check of this.#readinessChecks) check(normalizeMediaError(cancelledCaptureError()));
4582
5135
  this.#cancelCaptures();
4583
5136
  for (const unsubscribe of [this.#unsubscribeMedia, this.#unsubscribeRequests, this.#unsubscribePause, this.#unsubscribeSync]) unsubscribe?.();
@@ -4593,9 +5146,6 @@ var MediaControllerRuntime = class {
4593
5146
  this.#unsubscribePorts = null;
4594
5147
  this.#ports = null;
4595
5148
  this.#resetObservations();
4596
- this.#screenAudioTrack?.stop();
4597
- this.#screenAudioTrack = null;
4598
- for (const track of this.#tracks.values()) track.stop();
4599
5149
  this.#clearTracks();
4600
5150
  this.#clearRequests();
4601
5151
  this.#publish();
@@ -4611,10 +5161,11 @@ var MediaControllerRuntime = class {
4611
5161
  const check = (failure2) => {
4612
5162
  if (failure2) return resume(Effect5.fail(failure2));
4613
5163
  if (this.#sources[source].desired.revision !== revision) return resume(Effect5.void);
4614
- 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())));
4615
5165
  const state = this.#connection.getSnapshot().state;
4616
- if (state === "live" && this.#ports?.sync.getSnapshot().connection.phase === "live" && this.#store.getSnapshot().connection.status === "live") return resume(Effect5.void);
4617
- 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"))));
4618
5169
  };
4619
5170
  this.#readinessChecks.add(check);
4620
5171
  const unsubscribe = this.#connection.subscribe(() => check());
@@ -4644,11 +5195,20 @@ var MediaControllerRuntime = class {
4644
5195
  #pauseTracks(source) {
4645
5196
  const track = this.#track(source);
4646
5197
  if (track) track.enabled = false;
5198
+ for (const publication of this.#ports?.media.getSnapshot().localTracks ?? []) if (publication.source === source) publication.track.enabled = false;
4647
5199
  if (source === "screen" && this.#screenAudioTrack) this.#screenAudioTrack.enabled = false;
4648
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
+ }
4649
5208
  #recordIntent(source, enabled, options) {
4650
5209
  const state = this.#sources[source];
4651
- 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 };
4652
5212
  state.desired = desired;
4653
5213
  if (!enabled) {
4654
5214
  state.localEnabled = false;
@@ -4660,12 +5220,7 @@ var MediaControllerRuntime = class {
4660
5220
  const ports = this.#ports;
4661
5221
  if (ports) {
4662
5222
  ports.media.setLocalSourceIntent?.(source, false);
4663
- const previous = state.pause;
4664
- const next = this.#beginLocalPause(ports, source);
4665
- state.pause = previous ? Promise.allSettled([previous, next]).then((results) => {
4666
- for (const result of results) if (result.status === "rejected") throw result.reason;
4667
- }) : next;
4668
- void state.pause.catch(() => void 0);
5223
+ this.#retainPause(state, this.#beginLocalPause(ports, source));
4669
5224
  }
4670
5225
  }
4671
5226
  for (const check of this.#readinessChecks) check();
@@ -4673,6 +5228,7 @@ var MediaControllerRuntime = class {
4673
5228
  }
4674
5229
  #act(source, enabled, options) {
4675
5230
  return Effect5.suspend(() => {
5231
+ if (this.#ports?.media.getSnapshot().connection.phase === "stopped") return Effect5.void;
4676
5232
  const desired = this.#recordIntent(source, enabled, options);
4677
5233
  const ready = enabled && source !== "screen" && this.#store.getSnapshot().connection.status !== "live" ? this.#waitForLive(source, desired.revision).pipe(
4678
5234
  Effect5.tapError(
@@ -4757,7 +5313,16 @@ var MediaControllerRuntime = class {
4757
5313
  const state = this.#sources[source];
4758
5314
  if (!intent.enabled && ports.sync.getSnapshot().connection.phase !== "live") return;
4759
5315
  const previous = intent.previousEnabled;
4760
- 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
+ }
4761
5326
  });
4762
5327
  }
4763
5328
  #prepareDevice(ports, source, intent) {
@@ -4867,21 +5432,26 @@ var MediaControllerRuntime = class {
4867
5432
  })
4868
5433
  );
4869
5434
  }
4870
- #assertCurrentIntent(source, revision) {
4871
- 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;
4872
5442
  }
4873
5443
  #resetObservations() {
5444
+ if (this.#connection.getSnapshot().state !== "reconnecting") this.#recovering = null;
4874
5445
  for (const source of MEDIA_SOURCES) {
4875
5446
  const state = this.#sources[source];
4876
5447
  state.result = { revision: state.desired.revision, status: "confirmed" };
4877
5448
  state.pause = null;
4878
5449
  state.serverSequence = -1;
4879
- state.forcedRevision = null;
4880
5450
  }
4881
5451
  }
4882
5452
  #pauseInitialTracks() {
4883
5453
  for (const source of ["microphone", "camera"]) {
4884
- const enabled = this.#sources[source].desired.enabled;
5454
+ const enabled = this.#hasConsent(source);
4885
5455
  const track = this.#track(source);
4886
5456
  if (track && !enabled) track.enabled = false;
4887
5457
  this.#sources[source].localEnabled = !!track && enabled;
@@ -4892,15 +5462,14 @@ var MediaControllerRuntime = class {
4892
5462
  const revisions = { microphone: this.#sources.microphone.initialRevision, camera: this.#sources.camera.initialRevision };
4893
5463
  const tracks = stream.getTracks().filter((track) => {
4894
5464
  const source = track.kind === "audio" ? "microphone" : "camera";
4895
- const state = this.#sources[source];
4896
- track.enabled = state.desired.enabled;
4897
- if (!state.desired.enabled) {
5465
+ const enabled = this.#hasConsent(source);
5466
+ track.enabled = enabled;
5467
+ if (!enabled) {
4898
5468
  this.#deleteTrack(source);
4899
5469
  if (source === "microphone") track.stop();
4900
5470
  }
4901
- return state.desired.enabled;
5471
+ return enabled;
4902
5472
  });
4903
- for (const source of ["microphone", "camera"]) this.#sources[source].sentRevision = revisions[source];
4904
5473
  await media.start(streamFromTracks(tracks));
4905
5474
  for (const source of ["microphone", "camera"]) {
4906
5475
  const state = this.#sources[source];
@@ -4913,11 +5482,10 @@ var MediaControllerRuntime = class {
4913
5482
  const command = this.#mediaAction(
4914
5483
  enabled,
4915
5484
  foreign2(async () => {
4916
- this.#assertCurrentIntent(source, intent.revision);
5485
+ this.#assertCurrentIntent(source, intent.revision, enabled);
4917
5486
  this.#assertActivePorts(ports, source === "screen" ? "startScreenShare" : source === "camera" ? "setCameraEnabled" : "setMicrophoneEnabled");
4918
- const state = this.#sources[source];
4919
- state.sentRevision = intent.revision;
4920
- 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);
4921
5489
  }).pipe(
4922
5490
  Effect5.andThen(enabled && source !== "screen" ? this.#waitForLive(source, intent.revision, ports) : Effect5.void),
4923
5491
  Effect5.tap(() => Effect5.try({ try: () => this.#assertActivePorts(ports, source === "screen" ? "startScreenShare" : source === "camera" ? "setCameraEnabled" : "setMicrophoneEnabled"), catch: (cause) => cause }))
@@ -4927,10 +5495,19 @@ var MediaControllerRuntime = class {
4927
5495
  }
4928
5496
  #reconcileIntents() {
4929
5497
  const live = this.#connection.getSnapshot().state === "live" && this.#ports?.sync.getSnapshot().connection.phase === "live";
5498
+ if (this.#syncLive && !live) this.#rememberRecoveryIntents();
4930
5499
  const resumed = live && !this.#syncLive;
4931
5500
  this.#syncLive = live;
4932
5501
  if (!resumed) return;
4933
- 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
+ }
4934
5511
  }
4935
5512
  #clearScreenAudio(media) {
4936
5513
  return foreign2(() => media.clearPreparedLocalTrack("screen_audio")).pipe(
@@ -4943,7 +5520,8 @@ var MediaControllerRuntime = class {
4943
5520
  );
4944
5521
  }
4945
5522
  #beginLocalPause(ports, source) {
4946
- 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));
4947
5525
  void pause.catch(() => void 0);
4948
5526
  return pause;
4949
5527
  }
@@ -5012,18 +5590,53 @@ var MediaControllerRuntime = class {
5012
5590
  #setTrack(source, track) {
5013
5591
  if (source !== "camera") {
5014
5592
  this.#tracks.set(source, track);
5593
+ if (source === "microphone") this.#watchMicrophone(track);
5015
5594
  return;
5016
5595
  }
5017
5596
  const state = this.#camera;
5018
5597
  if (!("owner" in state)) throw cancelledCaptureError();
5019
5598
  if (state.status === "live" && state.track !== track) throw cancelledCaptureError();
5020
- const onEnded = () => {
5021
- if (this.#camera.status === "live" && this.#camera.track === track) this.#transitionCamera({ status: "ended" });
5022
- };
5599
+ const onEnded = () => this.#handleCameraEnded(track);
5023
5600
  this.#transitionCamera({ status: "live", owner: state.owner, track, publication: this.#ports?.media ?? "initial", onEnded });
5024
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
+ }
5025
5635
  #deleteTrack(source) {
5026
5636
  if (source !== "camera") {
5637
+ if (source === "microphone") {
5638
+ this.#watchMicrophone(null);
5639
+ }
5027
5640
  this.#tracks.delete(source);
5028
5641
  return;
5029
5642
  }
@@ -5034,6 +5647,7 @@ var MediaControllerRuntime = class {
5034
5647
  } else this.#transitionCamera({ status: "ended" });
5035
5648
  }
5036
5649
  #clearTracks() {
5650
+ this.#watchMicrophone(null);
5037
5651
  this.#tracks.clear();
5038
5652
  this.#transitionCamera({ status: "idle" });
5039
5653
  }
@@ -5076,11 +5690,11 @@ var MediaControllerRuntime = class {
5076
5690
  #beginCameraCapture(acquire) {
5077
5691
  const abort = new AbortController();
5078
5692
  const owner = {
5079
- result: Deferred2.makeUnsafe(),
5693
+ result: Deferred3.makeUnsafe(),
5080
5694
  references: 0,
5081
5695
  cancel: () => {
5082
5696
  abort.abort();
5083
- Deferred2.doneUnsafe(owner.result, Effect5.fail(cancelledCaptureError()));
5697
+ Deferred3.doneUnsafe(owner.result, Effect5.fail(cancelledCaptureError()));
5084
5698
  }
5085
5699
  };
5086
5700
  this.#transitionCamera({ status: "capturing", owner });
@@ -5094,9 +5708,7 @@ var MediaControllerRuntime = class {
5094
5708
  track.stop();
5095
5709
  throw cancelledCaptureError();
5096
5710
  }
5097
- const onEnded = () => {
5098
- if (this.#camera.status === "live" && this.#camera.owner === owner) this.#transitionCamera({ status: "ended" });
5099
- };
5711
+ const onEnded = () => this.#handleCameraEnded(track);
5100
5712
  this.#transitionCamera({ status: "live", owner, track, publication: state.status === "live" ? state.publication : null, onEnded });
5101
5713
  if (track.readyState !== "live") {
5102
5714
  this.#transitionCamera({ status: "ended" });
@@ -5106,7 +5718,7 @@ var MediaControllerRuntime = class {
5106
5718
  catch: (cause) => cause
5107
5719
  })
5108
5720
  ),
5109
- Effect5.onExit((exit) => Deferred2.done(owner.result, exit)),
5721
+ Effect5.onExit((exit) => Deferred3.done(owner.result, exit)),
5110
5722
  Effect5.ignore
5111
5723
  )
5112
5724
  );
@@ -5122,7 +5734,7 @@ var MediaControllerRuntime = class {
5122
5734
  const current = this.#camera;
5123
5735
  if (owner.references === 0 && "owner" in current && current.owner === owner && (current.status !== "live" || current.publication === null)) this.#transitionCamera({ status: "ended" });
5124
5736
  };
5125
- return Deferred2.await(owner.result).pipe(
5737
+ return Deferred3.await(owner.result).pipe(
5126
5738
  Effect5.flatMap((track) => track.readyState === "live" ? Effect5.succeed({ track, release }) : Effect5.fail(cancelledCaptureError())),
5127
5739
  Effect5.onExit(
5128
5740
  (exit) => Effect5.sync(() => {
@@ -5152,9 +5764,11 @@ var MediaControllerRuntime = class {
5152
5764
  return Effect5.suspend(() => {
5153
5765
  let cancelled = false;
5154
5766
  let captured = null;
5767
+ const cancellation = Deferred3.makeUnsafe();
5155
5768
  const cancel = () => {
5156
5769
  cancelled = true;
5157
5770
  stopStream(captured);
5771
+ Deferred3.doneUnsafe(cancellation, Effect5.fail(cancelledCaptureError()));
5158
5772
  };
5159
5773
  this.#pendingCaptures.add(cancel);
5160
5774
  signal?.addEventListener("abort", cancel, { once: true });
@@ -5163,7 +5777,7 @@ var MediaControllerRuntime = class {
5163
5777
  if (cancelled) throw cancelledCaptureError();
5164
5778
  const stream = await operation();
5165
5779
  captured = stream;
5166
- if (cancelled) {
5780
+ if (cancelled || this.#ports?.media.getSnapshot().connection.phase === "stopped") {
5167
5781
  stopStream(stream);
5168
5782
  throw cancelledCaptureError();
5169
5783
  }
@@ -5171,6 +5785,7 @@ var MediaControllerRuntime = class {
5171
5785
  }).pipe(
5172
5786
  Effect5.mapError(onCaptureError),
5173
5787
  Effect5.flatMap(use),
5788
+ Effect5.raceFirst(Deferred3.await(cancellation)),
5174
5789
  Effect5.tap(() => cancelled ? Effect5.fail(cancelledCaptureError()) : Effect5.void),
5175
5790
  Effect5.onExit(
5176
5791
  (exit) => Effect5.sync(() => {
@@ -5186,6 +5801,9 @@ var MediaControllerRuntime = class {
5186
5801
  this.#transitionCamera({ status: "ended" });
5187
5802
  for (const cancel of this.#pendingCaptures) cancel();
5188
5803
  this.#pendingCaptures.clear();
5804
+ this.#screenAudioTrack?.stop();
5805
+ this.#screenAudioTrack = null;
5806
+ for (const track of this.#tracks.values()) track.stop();
5189
5807
  }
5190
5808
  #captureSource(source, signal) {
5191
5809
  return this.#captureStream(
@@ -5242,42 +5860,46 @@ var MediaControllerRuntime = class {
5242
5860
  const failure2 = isPermissionDenied(input.cause) || captureWasUnavailable ? captureError(input.cause, input.action, input.permissionMessage) : input.cause;
5243
5861
  return rollback.pipe(Effect5.andThen(Effect5.sync(() => this.#publish())), Effect5.andThen(Effect5.fail(failure2)));
5244
5862
  }
5863
+ #canUsePorts(ports) {
5864
+ return this.#ports?.sync === ports.sync && this.#ports.media === ports.media && ports.media.getSnapshot().connection.phase !== "stopped";
5865
+ }
5245
5866
  #assertActivePorts(ports, action) {
5246
- 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;
5247
5868
  throw new ConnectionError({ code: "invalid_state", action, recoverable: false, message: `${action} belongs to an inactive connection` });
5248
5869
  }
5249
5870
  #pausePublication(ports, source, revision) {
5250
5871
  const state = this.#sources[source];
5251
- 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;
5252
5873
  state.forcedRevision = revision;
5253
5874
  state.localEnabled = false;
5254
5875
  this.#pauseTracks(source);
5255
5876
  ports.media.setLocalSourceIntent?.(source, false);
5256
- if (ports.media.closeForcedLocalPublication) this.#fork(foreign2(() => ports.media.closeForcedLocalPublication(source)).pipe(Effect5.ignore));
5877
+ this.#retainPause(state, this.#beginLocalPause(ports, source));
5257
5878
  this.#publish();
5258
5879
  }
5259
5880
  #bind(ports) {
5881
+ if (ports?.media.getSnapshot().connection.phase === "stopped") this.#cancelCaptures();
5260
5882
  if (ports && this.#ports?.sync === ports.sync && this.#ports.media === ports.media) return;
5883
+ this.#rememberRecoveryIntents();
5261
5884
  for (const unsubscribe of [this.#unsubscribeMedia, this.#unsubscribeRequests, this.#unsubscribePause, this.#unsubscribeSync]) unsubscribe?.();
5262
5885
  this.#unsubscribeMedia = null;
5263
5886
  this.#unsubscribeRequests = null;
5264
5887
  this.#unsubscribePause = null;
5265
5888
  this.#unsubscribeSync = null;
5266
5889
  this.#ports = ports;
5890
+ this.#projection.reset();
5267
5891
  this.#syncLive = false;
5268
5892
  this.#bindCamera(ports);
5269
5893
  if (!ports) {
5270
5894
  this.#resetObservations();
5271
5895
  this.#cancelCaptures();
5272
- this.#screenAudioTrack?.stop();
5273
- this.#screenAudioTrack = null;
5274
- for (const track of this.#tracks.values()) track.stop();
5275
5896
  this.#clearTracks();
5276
5897
  this.#clearRequests();
5277
5898
  this.#publish();
5278
5899
  return;
5279
5900
  }
5280
5901
  this.#unsubscribeMedia = ports.media.subscribe(() => {
5902
+ if (ports.media.getSnapshot().connection.phase === "stopped") this.#cancelCaptures();
5281
5903
  this.#applyRemoteMediaState(ports, ports.sync.getSnapshot());
5282
5904
  this.#publish();
5283
5905
  });
@@ -5292,41 +5914,27 @@ var MediaControllerRuntime = class {
5292
5914
  const state = this.#sources[pause.source];
5293
5915
  if (pause.revision <= state.serverSequence) return;
5294
5916
  state.serverSequence = pause.revision;
5295
- this.#pausePublication(ports, pause.source, state.sentRevision);
5917
+ if (pause.revision > state.desired.controlRevision) this.#pausePublication(ports, pause.source, state.desired.revision);
5296
5918
  }) ?? null;
5297
5919
  }
5298
- #rememberPublications(snapshot) {
5299
- const publications = /* @__PURE__ */ new Map();
5300
- const own = (snapshot.media?.items ?? []).filter((item) => item.participantId === snapshot.participantId && item.enabled && item.publicationId !== null);
5301
- for (const item of own) {
5302
- if (item.source === "screen_audio" || item.publicationId === null) continue;
5303
- const state = this.#sources[item.source];
5304
- if (state.localEnabled && state.sentRevision === state.desired.revision) publications.set(item.source, { revision: state.sentRevision, publicationId: item.publicationId });
5305
- }
5306
- 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]));
5307
5923
  }
5308
- #pauseLostPublications(ports, snapshot, publications) {
5309
- 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 ?? []) {
5310
5928
  const current = snapshot.media?.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
5311
- 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);
5312
5930
  }
5313
5931
  }
5314
5932
  #subscribeSync(ports) {
5315
- let recovering = /* @__PURE__ */ new Map();
5316
- let lastLive = ports.sync.getSnapshot();
5317
- let wasLive = lastLive.connection.phase === "live";
5318
5933
  return ports.sync.subscribe((snapshot) => {
5319
- const live = snapshot.connection.phase === "live";
5320
- if (wasLive && !live) recovering = this.#rememberPublications(lastLive);
5321
- if (live && !wasLive) {
5322
- this.#pauseLostPublications(ports, snapshot, recovering);
5323
- recovering.clear();
5324
- }
5325
- if (live) lastLive = snapshot;
5326
- wasLive = live;
5327
5934
  this.#removeRequestsFromMissingParticipants(snapshot);
5328
5935
  this.#applyRemoteMediaState(ports, snapshot);
5329
5936
  this.#reconcileIntents();
5937
+ this.#publish();
5330
5938
  });
5331
5939
  }
5332
5940
  #bindCamera(ports) {
@@ -5400,14 +6008,64 @@ var MediaControllerRuntime = class {
5400
6008
  if (!track || this.#track("screen") !== track) return;
5401
6009
  this.#fork(this.#stopScreen().pipe(Effect5.ignore));
5402
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
+ }
5403
6060
  #publish() {
5404
6061
  const current = this.#store.getSnapshot();
5405
6062
  const snapshot = this.#ports?.media.getSnapshot();
5406
6063
  const state = this.#connection.getSnapshot().state;
6064
+ this.#observeProjections();
5407
6065
  const local = Object.freeze({
5408
- microphone: localMedia("microphone", this.#localTracks(), snapshot, this.#sources.microphone.localEnabled, state, this.#pendingSources.has("microphone") && this.#sources.microphone.desired.enabled),
5409
- camera: localMedia("camera", this.#localTracks(), snapshot, this.#sources.camera.localEnabled, state, this.#pendingSources.has("camera") && this.#sources.camera.desired.enabled),
5410
- 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)
5411
6069
  });
5412
6070
  const remote = Object.freeze((snapshot?.remoteTracks ?? []).map((track) => Object.freeze({ participantId: track.participantId, source: track.source, publicationId: track.publicationId, track: track.track })));
5413
6071
  const media = Object.freeze({
@@ -5424,6 +6082,18 @@ var MediaControllerRuntime = class {
5424
6082
  if (!sameMediaSlice(current.media, media)) this.#store.updateMedia(media);
5425
6083
  }
5426
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
+ }
5427
6097
  function cancelledCaptureError() {
5428
6098
  return new ConnectionError({ code: "invalid_state", action: null, recoverable: false, message: "Media capture was cancelled before its result could be used" });
5429
6099
  }
@@ -5432,57 +6102,35 @@ function foreign2(operation) {
5432
6102
  }
5433
6103
  function selectInitialTracks(stream, intent) {
5434
6104
  const tracks = stream.getTracks();
5435
- const microphone = initialTrackFor(tracks, intent.microphone, "audio");
5436
- const camera = initialTrackFor(tracks, intent.camera, "video");
5437
- const selected = new Set(initialTracks(microphone, camera));
5438
- for (const track of tracks) if (!selected.has(track)) track.stop();
5439
- if (missingInitialTrack(intent, microphone, camera)) {
5440
- 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();
5441
6118
  throw new TypeError("Media capture did not return every requested track");
5442
6119
  }
5443
- return new Map(initialTrackEntries(microphone, camera));
5444
- }
5445
- function initialTrackFor(tracks, enabled, kind) {
5446
- if (!enabled) return void 0;
5447
- return tracks.find((track) => track.kind === kind);
5448
- }
5449
- function initialTracks(microphone, camera) {
5450
- return [microphone, camera].filter((track) => track !== void 0);
5451
- }
5452
- function missingInitialTrack(intent, microphone, camera) {
5453
- return intent.microphone && !microphone || intent.camera && !camera;
5454
- }
5455
- function initialTrackEntries(microphone, camera) {
5456
- const entries = [];
5457
- if (microphone) entries.push(["microphone", microphone]);
5458
- if (camera) entries.push(["camera", camera]);
5459
- return entries;
6120
+ return selected;
5460
6121
  }
5461
- function localMedia(source, tracks, snapshot, intended, connectionState, pending) {
5462
- const track = tracks.get(source) ?? null;
5463
- const publication = snapshot?.localTracks.find((candidate) => candidate.source === source);
5464
- if (pending) return Object.freeze({ source, state: "requesting", track });
5465
- if (locallyMuted(intended, track, publication)) return Object.freeze({ source, state: "disabled", track });
5466
- if (publication?.enabled) return Object.freeze({ source, state: "enabled", track });
5467
- if (publication && connectionState === "live") return Object.freeze({ source, state: "disabled", track });
5468
- return Object.freeze({ source, state: localMediaState(source, track, intended, connectionState), track });
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 });
5469
6126
  }
5470
- function locallyMuted(intended, track, publication) {
5471
- return !intended && track?.enabled === false && publication?.enabled === false;
5472
- }
5473
- function localMediaState(source, track, intended, connectionState) {
5474
- if (!mediaIsDesired(source, track, intended)) return inactiveLocalMediaState(connectionState, track);
5475
- if (connectionState === "joining" || track) return "requesting";
5476
- return activeLocalMediaState(connectionState);
5477
- }
5478
- function mediaIsDesired(source, track, intended) {
5479
- return source === "screen" ? track !== null : intended;
5480
- }
5481
- function inactiveLocalMediaState(connectionState, track) {
5482
- return connectionState === "live" || connectionState === "reconnecting" || track ? "disabled" : "unavailable";
5483
- }
5484
- function activeLocalMediaState(connectionState) {
5485
- 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";
5486
6134
  }
5487
6135
  function sameMediaSlice(left, right) {
5488
6136
  if (left.selection !== right.selection || !sameDevices(left.devices, right.devices)) return false;
@@ -6489,7 +7137,7 @@ var SpaceClientCore = class {
6489
7137
  return this.#connection.leave().pipe(Effect12.mapError(normalizeClientError));
6490
7138
  }
6491
7139
  endEpisode() {
6492
- 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));
6493
7141
  }
6494
7142
  extendEpisode(minutes) {
6495
7143
  return this.#connection.runCommand(({ sync }) => foreign6(() => sync.extendEpisode(minutes))).pipe(Effect12.asVoid, Effect12.mapError(normalizeClientError));
@@ -6565,7 +7213,14 @@ function foreign6(operation) {
6565
7213
  }
6566
7214
  async function fetchAccessGrant2(input) {
6567
7215
  if (input.platform.connectionAccess) return input.platform.connectionAccess(input.request);
6568
- 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
+ });
6569
7224
  }
6570
7225
  function accessReason(reason) {
6571
7226
  if (reason === void 0 || reason === "join") return "join";