@q9labsai/chalk-client 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +39 -0
  3. package/dist/access/base64-url.d.ts +0 -1
  4. package/dist/access/grant.d.ts +0 -1
  5. package/dist/access/manager.d.ts +0 -1
  6. package/dist/chat-files/http-transport.d.ts +0 -1
  7. package/dist/chat-files/index.d.ts +0 -1
  8. package/dist/chat-files/types.d.ts +0 -1
  9. package/dist/{chunk-2HJIJVE2.js → chunk-3ZN7IXOZ.js} +341 -3
  10. package/dist/{chunk-O2NOZKJP.js → chunk-DWKTGBHR.js} +20 -8
  11. package/dist/{chunk-AVXJ4HQU.js → chunk-K7PNW3KI.js} +156 -35
  12. package/dist/{chunk-CMVO7USN.js → chunk-KHHULLBZ.js} +1 -1
  13. package/dist/{chunk-IPAN4LUZ.js → chunk-RJRSLXVT.js} +863 -409
  14. package/dist/client.d.ts +76 -1
  15. package/dist/collaboration/index.d.ts +0 -1
  16. package/dist/collaboration/types.d.ts +0 -1
  17. package/dist/collaboration/wire.d.ts +0 -1
  18. package/dist/connection/dependencies.d.ts +5 -3
  19. package/dist/connection/diagnostics.d.ts +1 -2
  20. package/dist/connection/index.d.ts +1 -1
  21. package/dist/connection/lifecycle.d.ts +2 -3
  22. package/dist/connection/media-devices.d.ts +0 -1
  23. package/dist/connection/production.d.ts +0 -1
  24. package/dist/connection/types.d.ts +2 -2
  25. package/dist/effect.d.ts +0 -1
  26. package/dist/effect.js +88 -4
  27. package/dist/foreground-deadline.d.ts +0 -1
  28. package/dist/generated/http-api.d.ts +76 -1
  29. package/dist/generated/schemas.d.ts +366 -7
  30. package/dist/generated/sync.d.ts +0 -1
  31. package/dist/generated/whiteboard-v1.d.ts +50 -7
  32. package/dist/index.d.ts +0 -1
  33. package/dist/index.js +7 -5
  34. package/dist/media/camera-uplink.d.ts +0 -1
  35. package/dist/media/capture-error.d.ts +0 -1
  36. package/dist/media/client.d.ts +3 -2
  37. package/dist/media/cloudflare-sfu.d.ts +0 -1
  38. package/dist/media/index.d.ts +0 -1
  39. package/dist/media/index.js +1 -1
  40. package/dist/media/observers.d.ts +0 -1
  41. package/dist/media/plane.d.ts +0 -1
  42. package/dist/media/remote-track-response.d.ts +0 -1
  43. package/dist/media/target.d.ts +0 -1
  44. package/dist/media/tracks.d.ts +0 -1
  45. package/dist/media/transport.d.ts +0 -1
  46. package/dist/media/types.d.ts +0 -1
  47. package/dist/public-invites/client.d.ts +0 -1
  48. package/dist/public-invites/index.d.ts +0 -1
  49. package/dist/public-invites/index.js +2 -2
  50. package/dist/public-invites/types.d.ts +0 -1
  51. package/dist/server/access-refresh.d.ts +0 -1
  52. package/dist/server/{chunk-C4XITY5U.js → chunk-XQOQTCUL.js} +22 -0
  53. package/dist/server/client.d.ts +0 -1
  54. package/dist/server/errors.d.cts +8 -1
  55. package/dist/server/errors.d.ts +8 -2
  56. package/dist/server/index.cjs +31 -7
  57. package/dist/server/index.d.cts +1 -1
  58. package/dist/server/index.d.ts +1 -2
  59. package/dist/server/index.js +10 -7
  60. package/dist/server/server-only.d.ts +0 -1
  61. package/dist/server/server-only.js +1 -1
  62. package/dist/server/transport.d.ts +0 -1
  63. package/dist/server/types.d.ts +0 -1
  64. package/dist/space-client/chat-controller-helpers.d.ts +0 -1
  65. package/dist/space-client/chat-controller.d.ts +0 -1
  66. package/dist/space-client/controller-effects.d.ts +0 -1
  67. package/dist/space-client/core.d.ts +0 -1
  68. package/dist/space-client/effect.d.ts +0 -1
  69. package/dist/space-client/errors.d.ts +4 -2
  70. package/dist/space-client/index.d.ts +1 -2
  71. package/dist/space-client/media-controller.d.ts +3 -2
  72. package/dist/space-client/media-device-selection.d.ts +0 -1
  73. package/dist/space-client/offline-actions-storage.d.ts +0 -1
  74. package/dist/space-client/offline-actions.d.ts +0 -1
  75. package/dist/space-client/participants-controller.d.ts +0 -1
  76. package/dist/space-client/promise-facade.d.ts +0 -1
  77. package/dist/space-client/reactions-controller.d.ts +0 -1
  78. package/dist/space-client/recording-controller.d.ts +0 -1
  79. package/dist/space-client/reload-rejoin.d.ts +0 -1
  80. package/dist/space-client/space-client.d.ts +0 -1
  81. package/dist/space-client/store.d.ts +0 -1
  82. package/dist/space-client/types.d.ts +7 -2
  83. package/dist/space-client/whiteboard-controller.d.ts +0 -1
  84. package/dist/sync/browser.d.ts +0 -1
  85. package/dist/sync/canonical.d.ts +0 -1
  86. package/dist/sync/dial-deadline.d.ts +3 -0
  87. package/dist/sync/errors.d.ts +0 -1
  88. package/dist/sync/index.d.ts +0 -1
  89. package/dist/sync/index.js +1 -1
  90. package/dist/sync/react-native.d.ts +0 -1
  91. package/dist/sync/types.d.ts +0 -1
  92. package/dist/sync/v1-client.d.ts +2 -2
  93. package/dist/sync/v1-codec.d.ts +0 -1
  94. package/dist/sync/v1-collaboration-state.d.ts +1 -1
  95. package/dist/sync/v1-command-scheduler.d.ts +0 -1
  96. package/dist/sync/v1-create.d.ts +0 -1
  97. package/dist/sync/v1-deferred.d.ts +0 -1
  98. package/dist/sync/v1-error.d.ts +0 -1
  99. package/dist/sync/v1-frame-signature.d.ts +0 -1
  100. package/dist/sync/v1-health.d.ts +15 -0
  101. package/dist/sync/v1-live-target-coordinator.d.ts +0 -1
  102. package/dist/sync/v1-persistence.d.ts +0 -1
  103. package/dist/sync/v1-platform-persistence.d.ts +0 -1
  104. package/dist/sync/v1-reducer.d.ts +3 -1
  105. package/dist/sync/v1-types.d.ts +10 -1
  106. package/dist/webhooks/errors.d.ts +0 -1
  107. package/dist/webhooks/generated/event-v1.d.ts +0 -1
  108. package/dist/webhooks/generated/fixtures-v1.d.ts +0 -1
  109. package/dist/webhooks/index.d.ts +0 -1
  110. package/dist/webhooks/processor.d.ts +0 -1
  111. package/dist/webhooks/server-only.d.ts +0 -1
  112. package/dist/webhooks/test.d.ts +0 -1
  113. package/dist/webhooks/types.d.ts +0 -1
  114. package/dist/webhooks/validate.d.ts +0 -1
  115. package/dist/webhooks/verify.d.ts +0 -1
  116. package/dist/whiteboard/file-download-transport.d.ts +0 -1
  117. package/dist/whiteboard/index.d.ts +5 -1
  118. package/dist/whiteboard/types.d.ts +28 -2
  119. package/dist/whiteboard/v1-client.d.ts +3 -2
  120. package/dist/whiteboard/v1-codec.d.ts +0 -1
  121. package/dist/whiteboard/v1-create.d.ts +0 -1
  122. package/dist/whiteboard/v1-multipart.d.ts +0 -1
  123. package/dist/whiteboard/v1-persistence.d.ts +0 -1
  124. package/package.json +4 -2
  125. package/dist/access/base64-url.d.ts.map +0 -1
  126. package/dist/access/grant.d.ts.map +0 -1
  127. package/dist/access/grant.test.helpers.d.ts +0 -10
  128. package/dist/access/grant.test.helpers.d.ts.map +0 -1
  129. package/dist/access/manager.d.ts.map +0 -1
  130. package/dist/chat-files/http-transport.d.ts.map +0 -1
  131. package/dist/chat-files/index.d.ts.map +0 -1
  132. package/dist/chat-files/types.d.ts.map +0 -1
  133. package/dist/client.d.ts.map +0 -1
  134. package/dist/collaboration/index.d.ts.map +0 -1
  135. package/dist/collaboration/types.d.ts.map +0 -1
  136. package/dist/collaboration/wire.d.ts.map +0 -1
  137. package/dist/connection/dependencies.d.ts.map +0 -1
  138. package/dist/connection/diagnostics.d.ts.map +0 -1
  139. package/dist/connection/index.d.ts.map +0 -1
  140. package/dist/connection/lifecycle.d.ts.map +0 -1
  141. package/dist/connection/media-devices.d.ts.map +0 -1
  142. package/dist/connection/production.d.ts.map +0 -1
  143. package/dist/connection/types.d.ts.map +0 -1
  144. package/dist/effect.d.ts.map +0 -1
  145. package/dist/foreground-deadline.d.ts.map +0 -1
  146. package/dist/generated/http-api.d.ts.map +0 -1
  147. package/dist/generated/schemas.d.ts.map +0 -1
  148. package/dist/generated/sync.d.ts.map +0 -1
  149. package/dist/generated/whiteboard-v1.d.ts.map +0 -1
  150. package/dist/index.d.ts.map +0 -1
  151. package/dist/media/camera-uplink.d.ts.map +0 -1
  152. package/dist/media/capture-error.d.ts.map +0 -1
  153. package/dist/media/client.d.ts.map +0 -1
  154. package/dist/media/cloudflare-sfu.d.ts.map +0 -1
  155. package/dist/media/index.d.ts.map +0 -1
  156. package/dist/media/observers.d.ts.map +0 -1
  157. package/dist/media/plane.d.ts.map +0 -1
  158. package/dist/media/remote-track-response.d.ts.map +0 -1
  159. package/dist/media/target.d.ts.map +0 -1
  160. package/dist/media/tracks.d.ts.map +0 -1
  161. package/dist/media/transport.d.ts.map +0 -1
  162. package/dist/media/types.d.ts.map +0 -1
  163. package/dist/public-invites/client.d.ts.map +0 -1
  164. package/dist/public-invites/index.d.ts.map +0 -1
  165. package/dist/public-invites/types.d.ts.map +0 -1
  166. package/dist/server/access-refresh.d.ts.map +0 -1
  167. package/dist/server/client.d.ts.map +0 -1
  168. package/dist/server/errors.d.ts.map +0 -1
  169. package/dist/server/index.d.ts.map +0 -1
  170. package/dist/server/server-only.d.ts.map +0 -1
  171. package/dist/server/transport.d.ts.map +0 -1
  172. package/dist/server/types.d.ts.map +0 -1
  173. package/dist/space-client/chat-controller-helpers.d.ts.map +0 -1
  174. package/dist/space-client/chat-controller.d.ts.map +0 -1
  175. package/dist/space-client/controller-effects.d.ts.map +0 -1
  176. package/dist/space-client/core.d.ts.map +0 -1
  177. package/dist/space-client/core.test.helpers.d.ts +0 -15
  178. package/dist/space-client/core.test.helpers.d.ts.map +0 -1
  179. package/dist/space-client/effect.d.ts.map +0 -1
  180. package/dist/space-client/errors.d.ts.map +0 -1
  181. package/dist/space-client/index.d.ts.map +0 -1
  182. package/dist/space-client/media-controller.d.ts.map +0 -1
  183. package/dist/space-client/media-device-selection.d.ts.map +0 -1
  184. package/dist/space-client/offline-actions-storage.d.ts.map +0 -1
  185. package/dist/space-client/offline-actions.d.ts.map +0 -1
  186. package/dist/space-client/participants-controller.d.ts.map +0 -1
  187. package/dist/space-client/promise-facade.d.ts.map +0 -1
  188. package/dist/space-client/reactions-controller.d.ts.map +0 -1
  189. package/dist/space-client/recording-controller.d.ts.map +0 -1
  190. package/dist/space-client/reload-rejoin.d.ts.map +0 -1
  191. package/dist/space-client/space-client.d.ts.map +0 -1
  192. package/dist/space-client/store.d.ts.map +0 -1
  193. package/dist/space-client/terminal.test.helpers.d.ts +0 -136
  194. package/dist/space-client/terminal.test.helpers.d.ts.map +0 -1
  195. package/dist/space-client/types.d.ts.map +0 -1
  196. package/dist/space-client/whiteboard-controller.d.ts.map +0 -1
  197. package/dist/sync/browser.d.ts.map +0 -1
  198. package/dist/sync/canonical.d.ts.map +0 -1
  199. package/dist/sync/errors.d.ts.map +0 -1
  200. package/dist/sync/index.d.ts.map +0 -1
  201. package/dist/sync/react-native.d.ts.map +0 -1
  202. package/dist/sync/types.d.ts.map +0 -1
  203. package/dist/sync/v1-client.d.ts.map +0 -1
  204. package/dist/sync/v1-codec.d.ts.map +0 -1
  205. package/dist/sync/v1-collaboration-state.d.ts.map +0 -1
  206. package/dist/sync/v1-command-scheduler.d.ts.map +0 -1
  207. package/dist/sync/v1-create.d.ts.map +0 -1
  208. package/dist/sync/v1-deferred.d.ts.map +0 -1
  209. package/dist/sync/v1-error.d.ts.map +0 -1
  210. package/dist/sync/v1-frame-signature.d.ts.map +0 -1
  211. package/dist/sync/v1-live-target-coordinator.d.ts.map +0 -1
  212. package/dist/sync/v1-persistence.d.ts.map +0 -1
  213. package/dist/sync/v1-platform-persistence.d.ts.map +0 -1
  214. package/dist/sync/v1-reducer.d.ts.map +0 -1
  215. package/dist/sync/v1-types.d.ts.map +0 -1
  216. package/dist/webhooks/errors.d.ts.map +0 -1
  217. package/dist/webhooks/generated/event-v1.d.ts.map +0 -1
  218. package/dist/webhooks/generated/fixtures-v1.d.ts.map +0 -1
  219. package/dist/webhooks/index.d.ts.map +0 -1
  220. package/dist/webhooks/processor.d.ts.map +0 -1
  221. package/dist/webhooks/server-only.d.ts.map +0 -1
  222. package/dist/webhooks/test.d.ts.map +0 -1
  223. package/dist/webhooks/types.d.ts.map +0 -1
  224. package/dist/webhooks/validate.d.ts.map +0 -1
  225. package/dist/webhooks/verify.d.ts.map +0 -1
  226. package/dist/whiteboard/file-download-transport.d.ts.map +0 -1
  227. package/dist/whiteboard/index.d.ts.map +0 -1
  228. package/dist/whiteboard/types.d.ts.map +0 -1
  229. package/dist/whiteboard/v1-client.d.ts.map +0 -1
  230. package/dist/whiteboard/v1-codec.d.ts.map +0 -1
  231. package/dist/whiteboard/v1-create.d.ts.map +0 -1
  232. package/dist/whiteboard/v1-multipart.d.ts.map +0 -1
  233. package/dist/whiteboard/v1-persistence.d.ts.map +0 -1
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  AccessGrantError,
3
3
  requireParsedAccessGrant
4
- } from "./chunk-2HJIJVE2.js";
4
+ } from "./chunk-3ZN7IXOZ.js";
5
5
  import {
6
6
  CloudflareSFUClient,
7
7
  createCloudflareSFUHTTPTransport,
8
8
  describeMediaCaptureError
9
- } from "./chunk-O2NOZKJP.js";
9
+ } from "./chunk-DWKTGBHR.js";
10
10
  import {
11
11
  ChatAttachmentMimeTypeSchema,
12
12
  ChatReactionSetFrameSchema,
@@ -16,8 +16,9 @@ import {
16
16
  V1SyncError,
17
17
  createBrowserSyncLifecycle,
18
18
  createBrowserWebSocketFactory,
19
- createV1SyncClient
20
- } from "./chunk-AVXJ4HQU.js";
19
+ createV1SyncClient,
20
+ scheduleSyncDialDeadline
21
+ } from "./chunk-K7PNW3KI.js";
21
22
 
22
23
  // src/access/manager.ts
23
24
  import { Clock, Context, Data, Effect, Layer, SynchronizedRef } from "effect";
@@ -80,7 +81,7 @@ function fetchAccessGrant(provider, input) {
80
81
  try: () => validateFetchedAccess(currentAccess, next, now, shouldReplaceMediaConnection),
81
82
  catch: (cause) => new ConnectionAccessFailure({ code: "access.invalid", cause })
82
83
  });
83
- return [{ access: next, automaticMediaReplacement: mediaExpired && !input.replaceMediaConnection }, { access: next }];
84
+ return [{ access: next, automaticMediaReplacement: currentAccess !== null && !input.replaceMediaConnection && !hasSameMediaConnection(currentAccess, next) }, { access: next }];
84
85
  });
85
86
  }
86
87
  function connectionAccessRequest(input, replaceMediaConnection) {
@@ -114,9 +115,10 @@ function validateExpiration(access, now) {
114
115
  }
115
116
  function validateRefresh(previous, next, replaceMediaConnection) {
116
117
  if (!hasSameSubject(previous, next)) throw new TypeError("Access grant refresh changed its subject");
117
- const mediaConnectionChanged = previous.media.provider !== next.media.provider || mediaBinding(previous) !== mediaBinding(next);
118
- if (replaceMediaConnection === mediaConnectionChanged) return;
119
- throw new TypeError(replaceMediaConnection ? "Access grant replacement did not change its media binding" : "Access grant refresh unexpectedly replaced its media connection");
118
+ if (replaceMediaConnection && hasSameMediaConnection(previous, next)) throw new TypeError("Access grant replacement did not change its media binding");
119
+ }
120
+ function hasSameMediaConnection(previous, next) {
121
+ return previous.media.provider === next.media.provider && mediaBinding(previous) === mediaBinding(next);
120
122
  }
121
123
  function hasSameSubject(previous, next) {
122
124
  const left = previous.subject;
@@ -383,6 +385,11 @@ var WhiteboardV1ProtocolMetadata = {
383
385
  presentation: {
384
386
  name: "presentation_v1",
385
387
  exactFields: ["name"]
388
+ },
389
+ view: {
390
+ name: "view_v1",
391
+ exactFields: ["name"],
392
+ extendedExactFields: ["name", "origins"]
386
393
  }
387
394
  },
388
395
  identity: {
@@ -399,7 +406,7 @@ var WhiteboardV1ProtocolMetadata = {
399
406
  },
400
407
  {
401
408
  id: "live",
402
- accepts: ["submit_update", "submit_update_part", "request_snapshot", "snapshot_ack", "clear", "set_draw_permission", "set_presentation", "cursor", "ping"],
409
+ accepts: ["submit_update", "submit_update_part", "request_snapshot", "snapshot_ack", "clear", "set_draw_permission", "set_presentation", "cursor", "view", "ping"],
403
410
  heartbeatIntervalMs: 2e4,
404
411
  missedDeadlinesBeforeClose: 2
405
412
  }
@@ -479,6 +486,14 @@ var WhiteboardV1ProtocolMetadata = {
479
486
  clientExactFields: ["type", "x", "y"],
480
487
  serverExactFields: ["type", "participant_id", "display_name", "x", "y", "occurred_at"]
481
488
  },
489
+ view: {
490
+ maxCoordinate: 1e6,
491
+ maxExtent: 2e6,
492
+ epochField: "origin_epoch",
493
+ originFields: ["origin_participant_id", "origin_sequence"],
494
+ clientExactFields: ["type", "x", "y", "width", "height", "bring"],
495
+ serverExactFields: ["type", "participant_id", "display_name", "x", "y", "width", "height", "bring", "occurred_at"]
496
+ },
482
497
  welcome: {
483
498
  exactFields: ["type", "protocol", "participant_id", "participant_generation", "capabilities", "participant_capabilities", "scene_id", "revision", "can_draw"],
484
499
  extendedExactFields: ["type", "protocol", "participant_id", "participant_generation", "capabilities", "participant_capabilities", "scene_id", "revision", "can_draw", "presenting"]
@@ -508,8 +523,8 @@ var WhiteboardV1ProtocolMetadata = {
508
523
  exactFields: ["type", "correlation_id", "operation", "code", "recoverable", "message"]
509
524
  }
510
525
  },
511
- clientFrames: ["hello", "submit_update", "submit_update_part", "request_snapshot", "snapshot_ack", "clear", "set_draw_permission", "set_presentation", "cursor", "ping"],
512
- serverFrames: ["welcome", "snapshot_page", "update", "update_part", "commit", "cursor", "permission_updated", "presentation_updated", "reset_required", "operation_error", "pong"],
526
+ clientFrames: ["hello", "submit_update", "submit_update_part", "request_snapshot", "snapshot_ack", "clear", "set_draw_permission", "set_presentation", "cursor", "view", "ping"],
527
+ serverFrames: ["welcome", "snapshot_page", "update", "update_part", "commit", "cursor", "view", "permission_updated", "presentation_updated", "reset_required", "operation_error", "pong"],
513
528
  receiptOperations: ["submit_update", "clear", "set_draw_permission", "set_presentation"],
514
529
  receiptOutcomes: ["committed", "duplicate"],
515
530
  resetReasons: ["scene_changed", "cursor_expired", "gap"],
@@ -612,7 +627,7 @@ function isClientFrame(value) {
612
627
  if (!isRecord2(value)) return false;
613
628
  switch (value.type) {
614
629
  case "hello":
615
- return (hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.hello.exactFields) || hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.hello.extendedExactFields) && isPresentationExtensions(value.extensions)) && value.protocol === "whiteboard-v1" && typeof value.token === "string" && utf8Bytes(value.token) > 0 && utf8Bytes(value.token) <= WhiteboardV1ProtocolLimits.tokenBytes && (value.cursor === null || isCursor(value.cursor));
630
+ return (hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.hello.exactFields) || hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.hello.extendedExactFields) && isExtensions(value.extensions)) && value.protocol === "whiteboard-v1" && typeof value.token === "string" && utf8Bytes(value.token) > 0 && utf8Bytes(value.token) <= WhiteboardV1ProtocolLimits.tokenBytes && (value.cursor === null || isCursor(value.cursor));
616
631
  case "submit_update":
617
632
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.submitUpdate.exactFields) && isBoundedIdentifier(value.operation_id, WhiteboardV1ProtocolLimits.operationIdMinBytes, WhiteboardV1ProtocolLimits.operationIdMaxBytes) && isUuid(value.scene_id) && typeof value.sync_all === "boolean" && isElementBatch(value.elements, WhiteboardV1ProtocolLimits.elementBatchMaxItems);
618
633
  case "submit_update_part":
@@ -629,17 +644,32 @@ function isClientFrame(value) {
629
644
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.setPresentation.exactFields) && isBoundedIdentifier(value.operation_id, WhiteboardV1ProtocolLimits.operationIdMinBytes, WhiteboardV1ProtocolLimits.operationIdMaxBytes) && typeof value.presenting === "boolean";
630
645
  case "cursor":
631
646
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.cursor.clientExactFields) && isFiniteNumber(value.x) && isFiniteNumber(value.y) && encodedWhiteboardV1FrameBytes(value) <= WhiteboardV1ProtocolLimits.cursorFrameBytes;
647
+ case "view":
648
+ return isViewFields(value, WhiteboardV1ProtocolMetadata.frames.view.clientExactFields) && isViewRect(value) && typeof value.bring === "boolean" && encodedWhiteboardV1FrameBytes(value) <= WhiteboardV1ProtocolLimits.cursorFrameBytes;
632
649
  case "ping":
633
650
  return hasExactKeys(value, ["type"]);
634
651
  default:
635
652
  return false;
636
653
  }
637
654
  }
655
+ function isViewFields(value, fields) {
656
+ if (hasExactRecordKeys(value, fields)) return true;
657
+ const originFields = [...fields, ...WhiteboardV1ProtocolMetadata.frames.view.originFields];
658
+ const validFields = hasExactRecordKeys(value, originFields) || hasExactRecordKeys(value, [...originFields, WhiteboardV1ProtocolMetadata.frames.view.epochField]) && isUuid(value.origin_epoch);
659
+ return validFields && isUuid(value.origin_participant_id) && Number.isSafeInteger(value.origin_sequence) && Number(value.origin_sequence) > 0;
660
+ }
661
+ function isViewRect(value) {
662
+ return isFiniteNumber(value.x) && isFiniteNumber(value.y) && isFiniteNumber(value.width) && isFiniteNumber(value.height) && value.width > 0 && value.height > 0 && Math.abs(value.x) <= WhiteboardV1ProtocolMetadata.frames.view.maxCoordinate && Math.abs(value.y) <= WhiteboardV1ProtocolMetadata.frames.view.maxCoordinate && value.width <= WhiteboardV1ProtocolMetadata.frames.view.maxExtent && value.height <= WhiteboardV1ProtocolMetadata.frames.view.maxExtent;
663
+ }
638
664
  function isAppState(value) {
639
665
  return value === null || hasExactKeys(value, WhiteboardV1ProtocolMetadata.sharedAppState.exactFields) && typeof value.view_background_color === "string" && utf8Bytes(value.view_background_color) <= 64;
640
666
  }
641
- function isPresentationExtensions(value) {
642
- return Array.isArray(value) && value.length === 1 && hasExactKeys(value[0], WhiteboardV1ProtocolMetadata.extensions.presentation.exactFields) && value[0].name === WhiteboardV1ProtocolMetadata.extensions.presentation.name;
667
+ function isExtension(value, extension) {
668
+ return hasExactKeys(value, extension.exactFields) && value.name === extension.name;
669
+ }
670
+ function isExtensions(value) {
671
+ const { presentation, view } = WhiteboardV1ProtocolMetadata.extensions;
672
+ return Array.isArray(value) && (value.length === 1 || value.length === 2) && isExtension(value[0], presentation) && (value.length === 1 || isExtension(value[1], view) || hasExactKeys(value[1], view.extendedExactFields) && value[1].name === view.name && value[1].origins === true);
643
673
  }
644
674
  function isWelcome(value) {
645
675
  const exact = hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.welcome.exactFields) || hasExactRecordKeys(value, WhiteboardV1ProtocolMetadata.frames.welcome.extendedExactFields) && typeof value.presenting === "boolean";
@@ -660,6 +690,8 @@ function isServerFrame(value) {
660
690
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.commit.exactFields) && isBoundedIdentifier(value.operation_id, WhiteboardV1ProtocolLimits.operationIdMinBytes, WhiteboardV1ProtocolLimits.operationIdMaxBytes) && typeof value.outcome === "string" && WhiteboardV1ProtocolMetadata.receiptOutcomes.some((outcome) => outcome === value.outcome) && isUuid(value.scene_id) && isUnsignedDecimal(value.revision);
661
691
  case "cursor":
662
692
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.cursor.serverExactFields) && isUuid(value.participant_id) && typeof value.display_name === "string" && utf8Bytes(value.display_name) > 0 && utf8Bytes(value.display_name) <= WhiteboardV1ProtocolLimits.displayNameMaxBytes && isFiniteNumber(value.x) && isFiniteNumber(value.y) && typeof value.occurred_at === "string" && !Number.isNaN(Date.parse(value.occurred_at)) && encodedWhiteboardV1FrameBytes(value) <= WhiteboardV1ProtocolLimits.cursorFrameBytes;
693
+ case "view":
694
+ return isViewFields(value, WhiteboardV1ProtocolMetadata.frames.view.serverExactFields) && isUuid(value.participant_id) && typeof value.display_name === "string" && utf8Bytes(value.display_name) > 0 && utf8Bytes(value.display_name) <= WhiteboardV1ProtocolLimits.displayNameMaxBytes && isViewRect(value) && typeof value.bring === "boolean" && typeof value.occurred_at === "string" && !Number.isNaN(Date.parse(value.occurred_at)) && encodedWhiteboardV1FrameBytes(value) <= WhiteboardV1ProtocolLimits.cursorFrameBytes;
663
695
  case "permission_updated":
664
696
  return hasExactKeys(value, WhiteboardV1ProtocolMetadata.frames.permissionUpdated.exactFields) && isUuid(value.participant_id) && typeof value.can_draw === "boolean";
665
697
  case "presentation_updated":
@@ -890,6 +922,7 @@ var ChalkWhiteboardV1Client = class {
890
922
  #options;
891
923
  #store;
892
924
  #listeners = /* @__PURE__ */ new Set();
925
+ #latestViews = /* @__PURE__ */ new Map();
893
926
  #summaryListeners = /* @__PURE__ */ new Set();
894
927
  #operations = /* @__PURE__ */ new Map();
895
928
  #reservedOperationIds = /* @__PURE__ */ new Set();
@@ -900,6 +933,7 @@ var ChalkWhiteboardV1Client = class {
900
933
  #updateAssembler = new WhiteboardV1UpdateAssembler();
901
934
  #updateAssemblyTimers = /* @__PURE__ */ new Map();
902
935
  #socket = null;
936
+ #cancelDial;
903
937
  #started = false;
904
938
  #startupGeneration = 0;
905
939
  #phase = "idle";
@@ -909,8 +943,12 @@ var ChalkWhiteboardV1Client = class {
909
943
  #capabilities = [];
910
944
  #canDraw = false;
911
945
  #presentationSupported = false;
946
+ #viewsSupported = false;
912
947
  #presenting = false;
913
- #useLegacyHello = false;
948
+ #hello = "versioned-view";
949
+ #viewSequence = 0;
950
+ #viewEpoch = crypto.randomUUID();
951
+ #pendingBring = null;
914
952
  #summaryStatus = "unsubscribed";
915
953
  #summaryError = null;
916
954
  #latestSnapshot = null;
@@ -957,7 +995,8 @@ var ChalkWhiteboardV1Client = class {
957
995
  if (this.#started && this.#startPromise) return this.#startPromise;
958
996
  this.#started = true;
959
997
  globalThis.document?.addEventListener("visibilitychange", this.#visibilityChanged);
960
- this.#useLegacyHello = false;
998
+ this.#hello = "versioned-view";
999
+ this.#viewsSupported = false;
961
1000
  this.#phase = "connecting";
962
1001
  this.#publishSummary("loading", null);
963
1002
  const generation = ++this.#startupGeneration;
@@ -981,11 +1020,15 @@ var ChalkWhiteboardV1Client = class {
981
1020
  }
982
1021
  #stopNow() {
983
1022
  this.#started = false;
1023
+ this.#viewsSupported = false;
1024
+ this.#pendingBring = null;
1025
+ this.#latestViews.clear();
984
1026
  this.#removeVisibilityListener();
985
1027
  this.#startupGeneration += 1;
986
1028
  this.#unsubscribeLifecycle?.();
987
1029
  this.#unsubscribeLifecycle = void 0;
988
1030
  this.#clearReconnect();
1031
+ this.#clearDial();
989
1032
  this.#clearHeartbeat();
990
1033
  this.#clearOperationRetryTimers();
991
1034
  this.#clearUpdateAssemblies();
@@ -1008,12 +1051,18 @@ var ChalkWhiteboardV1Client = class {
1008
1051
  subscribe(listener) {
1009
1052
  this.#listeners.add(listener);
1010
1053
  if (this.#phase !== "live") return () => this.#listeners.delete(listener);
1011
- if (this.#latestSnapshot && this.#latestSnapshot.sceneId === this.#sceneId && this.#latestSnapshot.revision === this.#revision) {
1012
- listener(this.#latestSnapshot);
1013
- }
1014
- if (this.#snapshots.size === 0 && !this.#waitingForOperations) void this.#requestSnapshot().catch(() => void 0);
1054
+ const currentSnapshot = this.#currentSnapshot();
1055
+ if (currentSnapshot) listener(currentSnapshot);
1056
+ for (const view of this.#latestViews.values()) listener({ ...view, bring: false });
1057
+ if (!currentSnapshot && this.#snapshots.size === 0 && !this.#waitingForOperations) void this.#requestSnapshot().catch(() => void 0);
1015
1058
  return () => this.#listeners.delete(listener);
1016
1059
  }
1060
+ #currentSnapshot() {
1061
+ const snapshot = this.#latestSnapshot;
1062
+ if (snapshot?.sceneId !== this.#sceneId) return null;
1063
+ if (snapshot?.revision !== this.#revision) return null;
1064
+ return snapshot;
1065
+ }
1017
1066
  subscribeSummary(listener) {
1018
1067
  this.#summaryListeners.add(listener);
1019
1068
  this.#notifySummaryListener(listener, this.#summary());
@@ -1034,7 +1083,7 @@ var ChalkWhiteboardV1Client = class {
1034
1083
  });
1035
1084
  }
1036
1085
  sendCursor(input) {
1037
- if (this.#phase !== "live" || this.#summaryStatus !== "ready" || this.#snapshots.size > 0 || !this.#canDraw) return;
1086
+ if (!this.#presenceReady || !this.#canDraw) return;
1038
1087
  const now = this.#now();
1039
1088
  if (now - this.#lastCursorAt < 1e3 / WhiteboardV1ProtocolLimits.cursorRatePerSecond) return;
1040
1089
  const frame = { type: "cursor", x: input.x, y: input.y };
@@ -1042,6 +1091,40 @@ var ChalkWhiteboardV1Client = class {
1042
1091
  this.#lastCursorAt = now;
1043
1092
  this.#send(frame);
1044
1093
  }
1094
+ get supportsViews() {
1095
+ return this.#started && this.#viewsSupported && this.#viewExtensionEnabled;
1096
+ }
1097
+ get #viewExtensionEnabled() {
1098
+ return this.#hello === "view" || this.#hello === "versioned-view";
1099
+ }
1100
+ get #presenceReady() {
1101
+ return this.#phase === "live" && this.#summaryStatus === "ready" && this.#snapshots.size === 0;
1102
+ }
1103
+ sendView(view) {
1104
+ if (!this.#started || !this.#viewExtensionEnabled) return false;
1105
+ const frame = this.#viewFrame(view);
1106
+ encodeWhiteboardV1ClientFrame(frame);
1107
+ if (!this.#presenceReady) {
1108
+ if (view.bring) this.#pendingBring = { sceneId: this.#sceneId, view: { ...view } };
1109
+ return true;
1110
+ }
1111
+ if (view.bring) this.#pendingBring = null;
1112
+ this.#send(frame);
1113
+ return true;
1114
+ }
1115
+ #viewFrame(view) {
1116
+ const rectangle = { type: "view", x: view.x, y: view.y, width: view.width, height: view.height, bring: view.bring };
1117
+ if (view.bring || this.#hello !== "versioned-view") return rectangle;
1118
+ if (view.originParticipantId !== void 0) return { ...rectangle, origin_participant_id: view.originParticipantId, origin_sequence: view.originSequence, ...view.originEpoch !== void 0 ? { origin_epoch: view.originEpoch } : {} };
1119
+ if (!this.#participantId) return rectangle;
1120
+ return { ...rectangle, origin_participant_id: this.#participantId, origin_sequence: ++this.#viewSequence, origin_epoch: this.#viewEpoch };
1121
+ }
1122
+ #flushPendingBring() {
1123
+ if (!this.#presenceReady) return;
1124
+ const pending = this.#pendingBring;
1125
+ this.#pendingBring = null;
1126
+ if (pending && (pending.sceneId === null || pending.sceneId === this.#sceneId)) this.sendView(pending.view);
1127
+ }
1045
1128
  requestSnapshot() {
1046
1129
  this.#assertLive("request_snapshot");
1047
1130
  return this.#requestSnapshot();
@@ -1128,7 +1211,21 @@ var ChalkWhiteboardV1Client = class {
1128
1211
  this.#phase = "connecting";
1129
1212
  const socket = this.#options.webSocket.connect(this.#options.url);
1130
1213
  this.#socket = socket;
1131
- socket.onopen = () => void this.#authenticate(socket);
1214
+ this.#cancelDial = scheduleSyncDialDeadline(
1215
+ this.#clock(),
1216
+ () => {
1217
+ this.#cancelDial = void 0;
1218
+ socket.close(CLIENT_RESTART_CLOSE_CODE, "WebSocket dial timeout");
1219
+ this.#disconnected(socket, CLIENT_RESTART_CLOSE_CODE);
1220
+ this.#publishSummary("recovering", failure("start_scene_subscription", "unavailable", true, "The board connection timed out before opening."));
1221
+ },
1222
+ this.#options.dialTimeoutMs
1223
+ );
1224
+ socket.onopen = () => {
1225
+ if (socket !== this.#socket) return;
1226
+ this.#clearDial();
1227
+ void this.#authenticate(socket);
1228
+ };
1132
1229
  socket.onmessage = (event) => {
1133
1230
  this.#inbound = this.#inbound.then(() => this.#receive(socket, event.data));
1134
1231
  };
@@ -1142,7 +1239,7 @@ var ChalkWhiteboardV1Client = class {
1142
1239
  this.#phase = "authenticating";
1143
1240
  this.#presentationSupported = false;
1144
1241
  const cursor = this.#sceneId && this.#revision ? { scene_id: this.#sceneId, revision: this.#revision } : null;
1145
- this.#send(this.#useLegacyHello ? { type: "hello", protocol: "whiteboard-v1", token, cursor } : { type: "hello", protocol: "whiteboard-v1", token, cursor, extensions: [{ name: "presentation_v1" }] });
1242
+ this.#send(helloFrame(this.#hello, token, cursor));
1146
1243
  } catch {
1147
1244
  socket.close(CLIENT_RESTART_CLOSE_CODE, "whiteboard authentication failed");
1148
1245
  }
@@ -1193,6 +1290,22 @@ var ChalkWhiteboardV1Client = class {
1193
1290
  occurredAt: frame.occurred_at
1194
1291
  });
1195
1292
  return;
1293
+ case "view": {
1294
+ this.#requireLive();
1295
+ const event = {
1296
+ type: "view",
1297
+ participantId: frame.participant_id,
1298
+ displayName: frame.display_name,
1299
+ x: frame.x,
1300
+ y: frame.y,
1301
+ width: frame.width,
1302
+ height: frame.height,
1303
+ bring: frame.bring,
1304
+ occurredAt: frame.occurred_at
1305
+ };
1306
+ this.#emit(frame.origin_participant_id ? { ...event, originParticipantId: frame.origin_participant_id, originSequence: frame.origin_sequence, ...frame.origin_epoch ? { originEpoch: frame.origin_epoch } : {} } : event);
1307
+ return;
1308
+ }
1196
1309
  case "permission_updated":
1197
1310
  this.#requireLive();
1198
1311
  if (frame.participant_id === this.#participantId) this.#canDraw = frame.can_draw;
@@ -1200,6 +1313,7 @@ var ChalkWhiteboardV1Client = class {
1200
1313
  return;
1201
1314
  case "presentation_updated":
1202
1315
  this.#requireLive();
1316
+ if (this.#sceneId !== frame.scene_id) this.#latestViews.clear();
1203
1317
  this.#sceneId = frame.scene_id;
1204
1318
  this.#revision = frame.revision;
1205
1319
  this.#presenting = frame.presenting;
@@ -1207,6 +1321,7 @@ var ChalkWhiteboardV1Client = class {
1207
1321
  return;
1208
1322
  case "reset_required":
1209
1323
  this.#requireLive();
1324
+ if (this.#sceneId !== frame.scene_id) this.#latestViews.clear();
1210
1325
  this.#sceneId = frame.scene_id;
1211
1326
  this.#revision = null;
1212
1327
  this.#publishSummary("recovering", failure("request_snapshot", "cursor_reset_required", true, "Whiteboard snapshot recovery is required."));
@@ -1227,6 +1342,7 @@ var ChalkWhiteboardV1Client = class {
1227
1342
  }
1228
1343
  #update(frame) {
1229
1344
  this.#requireLive();
1345
+ if (this.#sceneId !== frame.scene_id) this.#latestViews.clear();
1230
1346
  this.#sceneId = frame.scene_id;
1231
1347
  this.#revision = frame.revision;
1232
1348
  this.#publishSummary("ready", null);
@@ -1261,8 +1377,10 @@ var ChalkWhiteboardV1Client = class {
1261
1377
  #welcome(frame) {
1262
1378
  if (this.#phase !== "authenticating") throw new Error("unexpected whiteboard welcome");
1263
1379
  this.#phase = "live";
1380
+ this.#viewsSupported = this.#hello === "view" || this.#hello === "versioned-view";
1264
1381
  this.#reconnectAttempt = 0;
1265
1382
  this.#participantId = frame.participant_id;
1383
+ if (this.#sceneId !== frame.scene_id) this.#latestViews.clear();
1266
1384
  this.#sceneId = frame.scene_id;
1267
1385
  this.#revision = frame.revision;
1268
1386
  this.#capabilities = [...frame.capabilities];
@@ -1325,6 +1443,7 @@ var ChalkWhiteboardV1Client = class {
1325
1443
  }
1326
1444
  #completeSnapshot(frame, assembly, elements) {
1327
1445
  this.#snapshots.delete(frame.request_id);
1446
+ if (this.#sceneId !== frame.scene_id) this.#latestViews.clear();
1328
1447
  this.#sceneId = frame.scene_id;
1329
1448
  this.#revision = frame.revision;
1330
1449
  this.#publishSummary("ready", null);
@@ -1388,6 +1507,7 @@ var ChalkWhiteboardV1Client = class {
1388
1507
  if (!entry) return;
1389
1508
  this.#operations.delete(frame.operation_id);
1390
1509
  this.#clearOperationRetryTimer(frame.operation_id);
1510
+ if (this.#sceneId !== frame.scene_id) this.#latestViews.clear();
1391
1511
  this.#sceneId = frame.scene_id;
1392
1512
  this.#revision = frame.revision;
1393
1513
  this.#awaitingOperationIds.delete(frame.operation_id);
@@ -1450,10 +1570,11 @@ var ChalkWhiteboardV1Client = class {
1450
1570
  #disconnected(socket, closeCode) {
1451
1571
  if (socket !== this.#socket) return;
1452
1572
  const rejectedPresentationHello = this.#rejectedPresentationHello(closeCode);
1573
+ const wasLive = this.#phase === "live";
1453
1574
  this.#resetDisconnectedState();
1454
1575
  if (!this.#started || !this.#transportAvailable) return;
1455
1576
  if (rejectedPresentationHello) {
1456
- this.#useLegacyHello = true;
1577
+ this.#fallbackHello();
1457
1578
  this.#scheduleReconnect();
1458
1579
  return;
1459
1580
  }
@@ -1461,13 +1582,19 @@ var ChalkWhiteboardV1Client = class {
1461
1582
  this.#failRejectedConnection(closeCode);
1462
1583
  return;
1463
1584
  }
1585
+ if (wasLive) this.#hello = "versioned-view";
1464
1586
  this.#scheduleReconnect();
1465
1587
  }
1466
1588
  #rejectedPresentationHello(closeCode) {
1467
- return this.#phase === "authenticating" && !this.#useLegacyHello && terminalCloseCode(closeCode);
1589
+ return this.#phase === "authenticating" && this.#hello !== "legacy" && terminalCloseCode(closeCode);
1590
+ }
1591
+ #fallbackHello() {
1592
+ this.#hello = this.#hello === "versioned-view" ? "view" : this.#hello === "view" ? "presentation" : "legacy";
1593
+ if (this.#hello !== "view") this.#pendingBring = null;
1468
1594
  }
1469
1595
  #resetDisconnectedState() {
1470
1596
  this.#socket = null;
1597
+ this.#clearDial();
1471
1598
  this.#clearHeartbeat();
1472
1599
  this.#clearUpdateAssemblies();
1473
1600
  this.#phase = "connecting";
@@ -1484,6 +1611,10 @@ var ChalkWhiteboardV1Client = class {
1484
1611
  if (this.#initialSnapshot) rejectDeferred(this.#initialSnapshot, error(terminalFailure));
1485
1612
  this.#rejectOperationCallers(terminalFailure);
1486
1613
  }
1614
+ #clearDial() {
1615
+ this.#cancelDial?.();
1616
+ this.#cancelDial = void 0;
1617
+ }
1487
1618
  #scheduleReconnect() {
1488
1619
  this.#clearReconnect();
1489
1620
  const configuredDelay = this.#options.reconnectDelayMs;
@@ -1527,11 +1658,21 @@ var ChalkWhiteboardV1Client = class {
1527
1658
  }
1528
1659
  #emit(event) {
1529
1660
  if (event.type === "snapshot") this.#latestSnapshot = event;
1661
+ if (event.type === "view") {
1662
+ this.#latestViews.delete(event.participantId);
1663
+ this.#latestViews.set(event.participantId, event);
1664
+ if (this.#latestViews.size > 256) {
1665
+ const oldest = this.#latestViews.keys().next().value;
1666
+ if (oldest !== void 0) this.#latestViews.delete(oldest);
1667
+ }
1668
+ }
1530
1669
  for (const listener of this.#listeners) listener(event);
1531
1670
  }
1532
1671
  #publishSummary(status, summaryError) {
1533
1672
  this.#summaryStatus = status;
1534
1673
  this.#summaryError = summaryError;
1674
+ if (status === "failed") this.#pendingBring = null;
1675
+ if (status === "ready") this.#flushPendingBring();
1535
1676
  const summary = this.#summary();
1536
1677
  try {
1537
1678
  this.#options.onSummary?.(summary);
@@ -1752,6 +1893,12 @@ function operationName(frame) {
1752
1893
  function retryableOperationCode(code) {
1753
1894
  return code === "unavailable" || code === "overloaded" || code === "rate_limited" || code === "storage_unavailable";
1754
1895
  }
1896
+ function helloFrame(level, token, cursor) {
1897
+ const hello = { type: "hello", protocol: "whiteboard-v1", token, cursor };
1898
+ if (level === "legacy") return hello;
1899
+ if (level === "presentation") return { ...hello, extensions: [{ name: "presentation_v1" }] };
1900
+ return { ...hello, extensions: [{ name: "presentation_v1" }, level === "versioned-view" ? { name: "view_v1", origins: true } : { name: "view_v1" }] };
1901
+ }
1755
1902
  function terminalCloseCode(closeCode) {
1756
1903
  return closeCode === 1008 || closeCode === 1009;
1757
1904
  }
@@ -1792,6 +1939,12 @@ function createChalkWhiteboardV1Client(options) {
1792
1939
  });
1793
1940
  }
1794
1941
 
1942
+ // src/whiteboard/index.ts
1943
+ var ChalkWhiteboardViewBounds = {
1944
+ maxCoordinate: WhiteboardV1ProtocolMetadata.frames.view.maxCoordinate,
1945
+ maxExtent: WhiteboardV1ProtocolMetadata.frames.view.maxExtent
1946
+ };
1947
+
1795
1948
  // src/connection/types.ts
1796
1949
  var ConnectionError = class extends Error {
1797
1950
  code;
@@ -2123,6 +2276,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2123
2276
  syncNoticeUnresponsive: false,
2124
2277
  mediaSnapshot: null,
2125
2278
  failure: null,
2279
+ startInitialMedia: null,
2126
2280
  initialMedia: (intent) => Effect2.tryPromise({
2127
2281
  try: () => intent.microphone || intent.camera ? platform.mediaDevices.getUserMedia({ audio: intent.microphone, video: intent.camera }) : Promise.resolve(streamFromTracks([])),
2128
2282
  catch: (cause) => cause
@@ -2267,8 +2421,14 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2267
2421
  model.syncBindingCleanup?.();
2268
2422
  model.syncNoticeUnresponsive = false;
2269
2423
  let latestSnapshot = null;
2424
+ let observedDialTimeouts = 0;
2270
2425
  let snapshotQueued = false;
2271
2426
  const unsubscribe = sync.subscribe((snapshot) => {
2427
+ const count = snapshot.connection.dialTimeoutCount ?? 0;
2428
+ if (sync === model.sync && count > observedDialTimeouts) {
2429
+ observedDialTimeouts = count;
2430
+ diagnostics.record({ event: "sync_dial_timeout", state: model.state, epoch: model.epoch, attempt: count });
2431
+ }
2272
2432
  if (sync === model.sync && snapshot.connection.phase === "live") {
2273
2433
  const notice = snapshot.connection.noticeUnresponsive === true;
2274
2434
  if (notice !== model.syncNoticeUnresponsive) {
@@ -2330,7 +2490,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2330
2490
  const unsubscribe = sync.subscribe(observe);
2331
2491
  observe(sync.getSnapshot());
2332
2492
  return Effect2.sync(unsubscribe);
2333
- }).pipe((effect) => withinForegroundBudget(effect, timeoutMs, lifecycleFailure("sync_start_failed", true, "Sync did not become live before the startup deadline")));
2493
+ }).pipe((effect) => withinForegroundBudget(effect, timeoutMs, lifecycleFailure("sync_hello_timeout", true, "Sync did not become live before the startup deadline")));
2334
2494
  const trace = (step, effect) => {
2335
2495
  const span = diagnostics.startSpan({ step, state: model.state, epoch: model.epoch });
2336
2496
  return effect.pipe(
@@ -2388,7 +2548,7 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2388
2548
  [
2389
2549
  trace(
2390
2550
  "start_media",
2391
- foreign7(() => media.start(stream), "media_start_failed", "The media layer could not start")
2551
+ 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")
2392
2552
  ),
2393
2553
  trace("start_sync", foreign7(() => sync.start(), "sync_start_failed", "The sync layer could not start").pipe(Effect2.andThen(trace("wait_for_sync_live", waitForSyncLive(sync, boundedInteger(options.syncStartupTimeoutMs, START_TIMEOUT_MS, 1, 6e4))))))
2394
2554
  ],
@@ -2845,8 +3005,9 @@ var makeConnectionLifecycleLayerFromServices = (options) => Layer3.effect(
2845
3005
  },
2846
3006
  getDiagnostics: () => diagnostics.snapshot(),
2847
3007
  getJoinTrace: () => diagnostics.joinTrace(),
2848
- setInitialMedia: (provider, intent) => Effect2.sync(() => {
3008
+ setInitialMedia: (provider, intent, start) => Effect2.sync(() => {
2849
3009
  model.initialMedia = provider;
3010
+ model.startInitialMedia = start ?? null;
2850
3011
  model.initialMediaIntent = intent ?? null;
2851
3012
  }),
2852
3013
  configureJoin: (intent) => Effect2.sync(() => {
@@ -2907,7 +3068,7 @@ function snapshotFor(model, access) {
2907
3068
  state: model.state,
2908
3069
  subject: subjectFor(access),
2909
3070
  episode: episodeFor(access, control),
2910
- connection: Object.freeze({ sync: model.syncSnapshot?.connection.phase === "live" && model.syncNoticeUnresponsive ? "unresponsive" : syncPhase(model.syncSnapshot?.connection.phase), media: mediaPhase(model.mediaSnapshot?.connection.phase) }),
3071
+ connection: Object.freeze({ syncHealth: model.syncSnapshot?.connection.health, sync: model.syncSnapshot?.connection.phase === "live" && model.syncNoticeUnresponsive ? "unresponsive" : syncPhase(model.syncSnapshot?.connection.phase), media: mediaPhase(model.mediaSnapshot?.connection.phase) }),
2911
3072
  failure: model.failure ? Object.freeze({ ...model.failure }) : null,
2912
3073
  episodeEnded: model.episodeEndConfirmed,
2913
3074
  removedByHost: model.removedByHost
@@ -3027,6 +3188,7 @@ function mediaPhase(phase) {
3027
3188
  }
3028
3189
  function syncTerminalFailure(snapshot) {
3029
3190
  const reason = snapshot.connection.terminalReason;
3191
+ if (reason === "state_digest_recovery_failed") return lifecycleFailure("state_digest_recovery_failed", false, "The Episode state could not be recovered from an authoritative snapshot");
3030
3192
  if (reason === "episode_ended") return lifecycleFailure("episode_ended", false, "The Episode has ended");
3031
3193
  if (reason === "participant_inactive" || reason === "participant_removed" || reason === "stale_participant_generation") return lifecycleFailure("invalid_access", false, "Participant access is no longer valid");
3032
3194
  return lifecycleFailure("sync_start_failed", true, "The Sync transport needs recovery");
@@ -3045,6 +3207,8 @@ var CONNECTION_ERROR_CODE_MAP = {
3045
3207
  device_constraint_invalid: "media.device_constraint_invalid",
3046
3208
  media_capture_failed: "media.capture_failed",
3047
3209
  sync_start_failed: "connection.sync_start_failed",
3210
+ state_digest_recovery_failed: "connection.state_digest_recovery_failed",
3211
+ sync_hello_timeout: "connection.sync_hello_timeout",
3048
3212
  media_start_failed: "connection.media_start_failed",
3049
3213
  join_cleanup_unconfirmed: "connection.join_cleanup_unconfirmed",
3050
3214
  sync_recovery_exhausted: "connection.sync_recovery_exhausted",
@@ -3067,17 +3231,32 @@ var INVALID_PAYLOAD_CODE_BY_ACTION = /* @__PURE__ */ new Map([
3067
3231
  ["acceptMediaRequest", "media.request_invalid"],
3068
3232
  ["declineMediaRequest", "media.request_invalid"]
3069
3233
  ]);
3070
- function normalizeClientError(cause, fallback = "client.internal_error") {
3234
+ var MEDIA_TIMEOUT_FAILURE = { code: "media.timeout", recoverable: true, message: "Media did not respond in time. Try again." };
3235
+ var SYNC_FAILURES = /* @__PURE__ */ new Map([
3236
+ ["not_live", { failure: { code: "connection.invalid_state", recoverable: true, message: "Wait for the call to connect, then try again." } }],
3237
+ ["signaling_timeout", { failure: MEDIA_TIMEOUT_FAILURE }],
3238
+ ["negotiation_timeout", { failure: MEDIA_TIMEOUT_FAILURE }],
3239
+ ["media_timeout", { failure: MEDIA_TIMEOUT_FAILURE }],
3240
+ ["retry_exhausted", { failure: MEDIA_TIMEOUT_FAILURE, scope: "media" }],
3241
+ ["reaction_ack_timeout", { failure: { code: "collaboration.unavailable", recoverable: true, message: "Couldn\u2019t confirm your reaction. Try again." } }],
3242
+ ["overloaded", { failure: { code: "collaboration.unavailable", recoverable: true, message: "The call is busy right now. Try again." } }],
3243
+ ["rate_limited", { failure: { code: "command.rate_limited", recoverable: true, message: "You are sending messages too fast. Wait a moment, then try again." } }]
3244
+ ]);
3245
+ function normalizeMediaError(cause) {
3246
+ return normalizeClientError(cause, "client.internal_error", "media");
3247
+ }
3248
+ function normalizeClientError(cause, fallback = "client.internal_error", scope) {
3071
3249
  if (cause instanceof SpaceClientError) return cause;
3072
- if (isConnectionError(cause)) {
3250
+ if (cause instanceof ConnectionLifecycleFailure || isConnectionError(cause)) {
3073
3251
  return new SpaceClientError({
3074
- code: connectionErrorCode(cause.code, cause.action),
3252
+ code: connectionErrorCode(cause.code, cause instanceof ConnectionLifecycleFailure ? null : cause.action),
3075
3253
  recoverable: cause.recoverable,
3076
3254
  message: cause.message
3077
3255
  });
3078
3256
  }
3079
- if (cause instanceof V1SyncError && ["signaling_timeout", "negotiation_timeout", "media_timeout"].includes(cause.code)) {
3080
- return new SpaceClientError({ code: "media.timeout", recoverable: true, message: "Media did not respond in time. Try again." });
3257
+ if (cause instanceof V1SyncError) {
3258
+ const policy = SYNC_FAILURES.get(cause.code);
3259
+ if (policy && (!policy.scope || policy.scope === scope)) return new SpaceClientError(policy.failure);
3081
3260
  }
3082
3261
  if (isAccessInvalid(cause)) {
3083
3262
  return new SpaceClientError({ code: "access.invalid", recoverable: true, message: "The access grant was rejected" });
@@ -3146,6 +3325,7 @@ var SpaceStore = class _SpaceStore {
3146
3325
  const error2 = connectionError(snapshot.failure);
3147
3326
  const next = {
3148
3327
  status: connectionStatus(snapshot),
3328
+ syncHealth: snapshot.connection.syncHealth,
3149
3329
  episode: snapshot.episode,
3150
3330
  lastError: sameError(current.lastError, error2) ? current.lastError : error2,
3151
3331
  episodeEnded: snapshot.episodeEnded ?? false,
@@ -3199,27 +3379,15 @@ var SpaceStoreService = class extends Context4.Service()("@chalk/client/SpaceSto
3199
3379
  };
3200
3380
  var makeSpaceStoreLayer = Layer4.effect(SpaceStoreService, SpaceStore.make());
3201
3381
  var makeFakeSpaceStoreLayer = (store = new SpaceStore()) => Layer4.succeed(SpaceStoreService, store);
3202
- function mapCode(code) {
3203
- return {
3204
- invalid_access: "access.invalid",
3205
- access_unavailable: "access.unavailable",
3206
- episode_ended: "episode.ended",
3207
- invalid_payload: "chat.payload_invalid",
3208
- permission_denied: "media.permission_denied",
3209
- unsupported_environment: "environment.unsupported",
3210
- rate_limited: "command.rate_limited",
3211
- command_rejected: "command.rejected"
3212
- }[code] ?? "client.internal_error";
3213
- }
3214
3382
  function connectionError(failure2) {
3215
- return failure2 ? empty({ code: publicCode(failure2.code), recoverable: failure2.recoverable, message: failure2.message }) : null;
3383
+ return failure2 ? empty({ code: connectionErrorCode(failure2.code, failure2.action), recoverable: failure2.recoverable, message: failure2.message }) : null;
3216
3384
  }
3217
3385
  function connectionStatus(snapshot) {
3218
3386
  const interrupted = INTERRUPTED_CONNECTION_PHASES.has(snapshot.connection.sync) || INTERRUPTED_CONNECTION_PHASES.has(snapshot.connection.media);
3219
3387
  return snapshot.state === "live" && interrupted ? "reconnecting" : snapshot.state;
3220
3388
  }
3221
3389
  function sameConnection(left, right) {
3222
- return left.status === right.status && sameEpisode(left.episode, right.episode) && left.lastError === right.lastError && left.episodeEnded === right.episodeEnded && left.removedByHost === right.removedByHost;
3390
+ return left.status === right.status && left.syncHealth === right.syncHealth && sameEpisode(left.episode, right.episode) && left.lastError === right.lastError && left.episodeEnded === right.episodeEnded && left.removedByHost === right.removedByHost;
3223
3391
  }
3224
3392
  function sameEpisode(left, right) {
3225
3393
  if (left === right) return true;
@@ -3231,20 +3399,6 @@ function sameError(left, right) {
3231
3399
  if (!left || !right) return false;
3232
3400
  return left.code === right.code && left.recoverable === right.recoverable && left.message === right.message;
3233
3401
  }
3234
- function publicCode(code) {
3235
- switch (code) {
3236
- case "device_not_found":
3237
- return "media.device_not_found";
3238
- case "device_busy":
3239
- return "media.device_busy";
3240
- case "device_constraint_invalid":
3241
- return "media.device_constraint_invalid";
3242
- case "media_capture_failed":
3243
- return "media.capture_failed";
3244
- default:
3245
- return mapCode(code);
3246
- }
3247
- }
3248
3402
 
3249
3403
  // src/space-client/core.ts
3250
3404
  import { Clock as Clock5, Context as Context13, Duration as Duration2, Effect as Effect12, Layer as Layer13 } from "effect";
@@ -3786,8 +3940,11 @@ function chatError(message) {
3786
3940
  }
3787
3941
 
3788
3942
  // src/space-client/media-controller.ts
3789
- import { Clock as Clock3, Context as Context6, Effect as Effect5, Exit as Exit2, Layer as Layer6, Semaphore } from "effect";
3943
+ import { Clock as Clock3, Context as Context6, Deferred as Deferred2, Effect as Effect5, Exit as Exit2, Layer as Layer6, Semaphore } from "effect";
3790
3944
  var MEDIA_SOURCES = ["microphone", "camera", "screen"];
3945
+ function sourceWorker(enabled) {
3946
+ return { desired: { enabled, revision: 0, previousEnabled: enabled }, result: { revision: 0, status: "confirmed" }, sentRevision: 0, initialRevision: 0, localEnabled: enabled, pause: null, serverSequence: -1, forcedRevision: null };
3947
+ }
3791
3948
  var MediaControllerService = class extends Context6.Service()("@chalk/client/MediaController") {
3792
3949
  };
3793
3950
  var makeMediaController = (connection, store, selection) => Effect5.gen(function* () {
@@ -3812,7 +3969,8 @@ var makeMediaController = (connection, store, selection) => Effect5.gen(function
3812
3969
  );
3813
3970
  yield* connection.setInitialMedia(
3814
3971
  (intent) => controller.captureInitial(intent),
3815
- () => controller.initialIntent()
3972
+ () => controller.initialIntent(),
3973
+ (media, stream) => controller.startInitial(media, stream)
3816
3974
  );
3817
3975
  yield* Effect5.addFinalizer(() => Effect5.sync(() => controller.dispose()));
3818
3976
  controller.refreshDevicesInBackground();
@@ -3826,17 +3984,17 @@ var MediaControllerRuntime = class {
3826
3984
  #now;
3827
3985
  #fork;
3828
3986
  #tracks = /* @__PURE__ */ new Map();
3829
- #observedPublicationIds = /* @__PURE__ */ new Map();
3830
- #intentRevisions = /* @__PURE__ */ new Map();
3831
- #pendingEnables = /* @__PURE__ */ new Map();
3987
+ #sources = { microphone: sourceWorker(true), camera: sourceWorker(true), screen: sourceWorker(false) };
3988
+ #syncLive = false;
3832
3989
  #pendingSources = /* @__PURE__ */ new Set();
3990
+ #pendingCaptures = /* @__PURE__ */ new Set();
3833
3991
  #requestGenerations = /* @__PURE__ */ new Map();
3834
- #intent = { microphone: true, camera: true };
3992
+ #camera = { status: "idle" };
3835
3993
  #requests = Object.freeze([]);
3836
3994
  #ports = null;
3837
- #screenEndedPending = false;
3838
3995
  #screenAudioTrack = null;
3839
3996
  #unsubscribeMedia = null;
3997
+ #unsubscribePause = null;
3840
3998
  #unsubscribeRequests = null;
3841
3999
  #unsubscribeSync = null;
3842
4000
  #unsubscribeConnection = null;
@@ -3854,328 +4012,396 @@ var MediaControllerRuntime = class {
3854
4012
  this.#unsubscribePorts = connection.subscribePorts((ports) => this.#bind(ports));
3855
4013
  }
3856
4014
  configure(intent) {
3857
- if (intent.microphone !== void 0) this.#intent.microphone = intent.microphone;
3858
- if (intent.camera !== void 0) this.#intent.camera = intent.camera;
4015
+ if (intent.microphone !== void 0) this.#recordIntent("microphone", intent.microphone);
4016
+ if (intent.camera !== void 0) this.#recordIntent("camera", intent.camera);
3859
4017
  }
3860
- setMicrophoneEnabled = (enabled) => this.#set("microphone", enabled);
3861
- setCameraEnabled = (enabled) => this.#set("camera", enabled);
3862
- setScreenShareEnabled = (enabled, options) => enabled ? this.#startScreen(options) : this.#stopScreen();
4018
+ setMicrophoneEnabled = (enabled) => this.#mediaAction(enabled, this.#set("microphone", enabled));
4019
+ setCameraEnabled = (enabled) => this.#mediaAction(enabled, this.#set("camera", enabled));
4020
+ captureCameraPreview = () => this.#cameraSource("preview").pipe(Effect5.mapError(normalizeMediaError));
4021
+ setScreenShareEnabled = (enabled, options) => enabled ? this.#mediaAction(true, this.#startScreen(options)) : this.#stopScreen();
3863
4022
  selectMicrophone = (deviceId) => Effect5.sync(() => {
3864
4023
  this.#selection.selectCapture("microphone", deviceId);
3865
4024
  this.#store.select("microphone", deviceId);
3866
- }).pipe(Effect5.mapError(normalizeClientError));
4025
+ }).pipe(Effect5.mapError(normalizeMediaError));
3867
4026
  selectCamera = (deviceId) => Effect5.sync(() => {
4027
+ const changed = this.#store.getSnapshot().media.selection.camera !== deviceId;
3868
4028
  this.#selection.selectCapture("camera", deviceId);
4029
+ if (changed && (!this.#sources.camera.localEnabled || !this.#hasTrack("camera"))) {
4030
+ this.#transitionCamera({ status: "ended" });
4031
+ }
3869
4032
  this.#store.select("camera", deviceId);
3870
- }).pipe(Effect5.mapError(normalizeClientError));
4033
+ }).pipe(Effect5.mapError(normalizeMediaError));
3871
4034
  selectSpeaker = (deviceId) => foreign2(() => this.#selection.selectSpeaker(deviceId)).pipe(
3872
4035
  Effect5.tap(() => Effect5.sync(() => this.#store.select("speaker", deviceId))),
3873
- Effect5.mapError(normalizeClientError)
4036
+ Effect5.mapError(normalizeMediaError)
3874
4037
  );
3875
4038
  acceptRequest = (requestId) => {
3876
4039
  return Effect5.suspend(() => {
3877
4040
  const request = this.#requests.find((candidate) => candidate.requestId === requestId);
3878
4041
  if (!request) return Effect5.fail(new SpaceClientError({ code: "media.request_invalid", recoverable: false, message: "The media request is no longer active" }));
3879
- return (request.kind === "unmute" ? this.#set("microphone", true) : this.#set("camera", true)).pipe(
4042
+ return (request.kind === "unmute" ? this.setMicrophoneEnabled(true) : this.setCameraEnabled(true)).pipe(
3880
4043
  Effect5.tap(
3881
4044
  () => Effect5.sync(() => {
3882
4045
  this.#removeRequest(requestId);
3883
4046
  })
3884
4047
  )
3885
4048
  );
3886
- }).pipe(Effect5.mapError(normalizeClientError));
4049
+ }).pipe(Effect5.mapError(normalizeMediaError));
3887
4050
  };
3888
4051
  declineRequest = (requestId) => {
3889
4052
  return Effect5.sync(() => {
3890
4053
  this.#removeRequest(requestId);
3891
- }).pipe(Effect5.mapError(normalizeClientError));
4054
+ }).pipe(Effect5.mapError(normalizeMediaError));
3892
4055
  };
3893
4056
  initialIntent() {
3894
- return { ...this.#intent };
4057
+ return { microphone: this.#sources.microphone.desired.enabled, camera: this.#sources.camera.desired.enabled };
3895
4058
  }
3896
4059
  captureInitial(intent) {
4060
+ for (const source of ["microphone", "camera"]) this.#sources[source].initialRevision = this.#sources[source].desired.revision;
4061
+ if (!intent.camera) return this.#captureInitial(intent);
4062
+ return this.#useCamera(
4063
+ (track) => Effect5.sync(() => this.#setTrack("camera", track)).pipe(
4064
+ Effect5.andThen(Effect5.suspend(() => intent.microphone && !this.#hasTrack("microphone") ? this.#captureSource("microphone").pipe(Effect5.tap((audio) => Effect5.sync(() => this.#setTrack("microphone", audio)))) : Effect5.void)),
4065
+ Effect5.map(() => {
4066
+ this.#pauseInitialTracks();
4067
+ this.#publish();
4068
+ return streamFromTracks([...this.#localTracks().values()]);
4069
+ })
4070
+ ),
4071
+ (signal) => this.#captureInitial(intent, signal).pipe(
4072
+ Effect5.map((stream) => {
4073
+ const track = stream.getTracks().find((track2) => track2.kind === "video");
4074
+ if (!track) throw new TypeError("Initial capture did not return a camera track");
4075
+ return track;
4076
+ })
4077
+ )
4078
+ );
4079
+ }
4080
+ #captureInitial(intent, signal) {
3897
4081
  if (!intent.microphone && !intent.camera) {
3898
- this.#intent = { microphone: this.#tracks.has("microphone"), camera: this.#tracks.has("camera") };
4082
+ this.#pauseInitialTracks();
3899
4083
  this.#publish();
3900
- return Effect5.succeed(streamFromTracks([...this.#tracks.values()]));
4084
+ return Effect5.succeed(streamFromTracks([...this.#localTracks().values()]));
3901
4085
  }
3902
- return foreign2(() => this.#selection.getUserMedia({ audio: intent.microphone, video: intent.camera })).pipe(
3903
- Effect5.flatMap(
3904
- (stream) => Effect5.try({ try: () => selectInitialTracks(stream, intent), catch: (cause) => cause }).pipe(
3905
- Effect5.tap(
3906
- (tracks) => Effect5.sync(() => {
3907
- for (const [source, track] of tracks) this.#tracks.set(source, track);
3908
- this.#intent = { microphone: this.#tracks.has("microphone"), camera: this.#tracks.has("camera") };
3909
- this.#publish();
3910
- })
3911
- ),
3912
- Effect5.tap(() => this.#refreshDevices().pipe(Effect5.ignore)),
3913
- Effect5.map(() => streamFromTracks([...this.#tracks.values()])),
3914
- Effect5.catch(
3915
- (cause) => Effect5.sync(() => {
3916
- stopStream(stream);
3917
- this.#publish();
3918
- }).pipe(Effect5.andThen(Effect5.fail(captureError(cause, "join", "Camera or microphone permission was denied"))))
3919
- )
4086
+ return this.#captureStream(
4087
+ () => this.#selection.getUserMedia({ audio: intent.microphone, video: intent.camera }),
4088
+ (stream) => Effect5.try({ try: () => selectInitialTracks(stream, intent), catch: (cause) => cause }).pipe(
4089
+ Effect5.tap(
4090
+ (tracks) => Effect5.sync(() => {
4091
+ for (const [source, track] of tracks) this.#setTrack(source, track);
4092
+ this.#pauseInitialTracks();
4093
+ this.#publish();
4094
+ })
4095
+ ),
4096
+ Effect5.tap(() => this.#refreshDevices().pipe(Effect5.ignore)),
4097
+ Effect5.map(() => streamFromTracks([...this.#localTracks().values()])),
4098
+ Effect5.catch(
4099
+ (cause) => Effect5.sync(() => {
4100
+ stopStream(stream);
4101
+ this.#publish();
4102
+ }).pipe(Effect5.andThen(Effect5.fail(captureError(cause, "join", "Camera or microphone permission was denied"))))
3920
4103
  )
3921
4104
  ),
3922
- Effect5.catch((cause) => Effect5.fail(cause instanceof ConnectionError ? cause : captureError(cause, "join", "Camera or microphone permission was denied")))
3923
- );
4105
+ (cause) => cause,
4106
+ signal
4107
+ ).pipe(Effect5.catch((cause) => Effect5.fail(cause instanceof ConnectionError ? cause : captureError(cause, "join", "Camera or microphone permission was denied"))));
3924
4108
  }
3925
4109
  refreshDevicesInBackground() {
3926
4110
  this.#fork(this.#refreshDevices().pipe(Effect5.ignore));
3927
4111
  }
3928
4112
  dispose() {
3929
- this.#unsubscribeMedia?.();
3930
- this.#unsubscribeRequests?.();
3931
- this.#unsubscribeSync?.();
4113
+ this.#cancelCaptures();
4114
+ for (const unsubscribe of [this.#unsubscribeMedia, this.#unsubscribeRequests, this.#unsubscribePause, this.#unsubscribeSync]) unsubscribe?.();
3932
4115
  this.#unsubscribeConnection?.();
3933
4116
  this.#unsubscribeScreenEnded?.();
3934
4117
  this.#unsubscribePorts?.();
3935
4118
  this.#unsubscribeMedia = null;
3936
4119
  this.#unsubscribeRequests = null;
4120
+ this.#unsubscribePause = null;
3937
4121
  this.#unsubscribeSync = null;
3938
4122
  this.#unsubscribeConnection = null;
3939
4123
  this.#unsubscribeScreenEnded = null;
3940
4124
  this.#unsubscribePorts = null;
3941
4125
  this.#ports = null;
4126
+ this.#resetObservations();
3942
4127
  this.#screenAudioTrack?.stop();
3943
4128
  this.#screenAudioTrack = null;
3944
4129
  for (const track of this.#tracks.values()) track.stop();
3945
- this.#tracks.clear();
4130
+ this.#clearTracks();
3946
4131
  this.#clearRequests();
3947
4132
  this.#publish();
3948
4133
  }
4134
+ #mediaAction(enabling, operation) {
4135
+ return Effect5.suspend(() => !enabling || this.#store.getSnapshot().connection.status === "live" ? operation : Effect5.fail(normalizeMediaError(new V1SyncError("Media enable requires a live call", "not_live"))));
4136
+ }
3949
4137
  #set(source, enabled) {
3950
- let previousIntent = this.#intent[source];
3951
- let intentRevision = 0;
3952
- let earlyPause = null;
3953
- const localPause = Effect5.suspend(() => {
3954
- previousIntent = this.#intent[source];
3955
- intentRevision = (this.#intentRevisions.get(source) ?? 0) + 1;
3956
- this.#intentRevisions.set(source, intentRevision);
3957
- this.#intent[source] = enabled;
3958
- if (enabled) this.#pendingEnables.set(source, intentRevision);
3959
- this.#ports?.media.setLocalSourceIntent?.(source, enabled);
3960
- if (!enabled && this.#ports) earlyPause = this.#beginLocalPause(this.#ports, source);
3961
- return Effect5.void;
3962
- });
3963
- return localPause.pipe(
3964
- Effect5.andThen(
3965
- this.#serialize(
3966
- source,
3967
- this.#connection.runCommand((ports) => {
3968
- let captured = null;
3969
- let prepared = false;
3970
- const action = source === "microphone" ? "setMicrophoneEnabled" : "setCameraEnabled";
3971
- return Effect5.suspend(() => {
3972
- if (!enabled) {
3973
- let paused = false;
3974
- return (earlyPause ? foreign2(() => earlyPause) : this.#localTarget(ports, source, false)).pipe(
3975
- Effect5.tap(
3976
- () => Effect5.sync(() => {
3977
- paused = true;
3978
- })
3979
- ),
3980
- Effect5.andThen(source === "microphone" ? foreign2(() => ports.sync.setMicrophoneEnabled(false)) : foreign2(() => ports.sync.setCameraEnabled(false))),
3981
- Effect5.asVoid,
3982
- Effect5.catch((cause) => {
3983
- if (paused && !isSyncRejection(cause) || this.#intentRevisions.get(source) !== intentRevision) return Effect5.fail(cause);
3984
- this.#intent[source] = previousIntent;
3985
- ports.media.setLocalSourceIntent?.(source, previousIntent);
3986
- return this.#localTarget(ports, source, previousIntent).pipe(
3987
- Effect5.catch(() => Effect5.void),
3988
- Effect5.andThen(Effect5.fail(cause))
3989
- );
3990
- })
3991
- );
3992
- }
3993
- const acquire = enabled && !this.#tracks.has(source) ? this.#captureSource(source).pipe(
3994
- Effect5.tap(
3995
- (track) => Effect5.sync(() => {
3996
- captured = track;
3997
- try {
3998
- this.#assertActivePorts(ports, action);
3999
- } catch (cause) {
4000
- track.stop();
4001
- throw cause;
4002
- }
4003
- this.#tracks.set(source, track);
4004
- ports.media.prepareLocalTrack(source, track);
4005
- prepared = true;
4006
- this.#publish();
4007
- })
4008
- )
4009
- ) : Effect5.void;
4010
- return acquire.pipe(
4011
- Effect5.asVoid,
4012
- Effect5.andThen(source === "microphone" ? foreign2(() => ports.sync.setMicrophoneEnabled(enabled)) : foreign2(() => ports.sync.setCameraEnabled(enabled))),
4013
- Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, action))),
4014
- Effect5.asVoid,
4015
- Effect5.catch(
4016
- (cause) => this.#rollbackCapture({
4017
- action,
4018
- cause,
4019
- fallback: Effect5.void,
4020
- media: ports.media,
4021
- permissionMessage: `${source} permission was denied`,
4022
- prepared,
4023
- restoreIntent: () => {
4024
- if (this.#intentRevisions.get(source) === intentRevision) this.#intent[source] = previousIntent;
4025
- },
4026
- source,
4027
- track: captured
4028
- })
4029
- )
4030
- );
4031
- });
4032
- })
4033
- )
4034
- )
4035
- ).pipe(
4036
- Effect5.mapError(normalizeClientError),
4037
- Effect5.ensuring(
4038
- Effect5.sync(() => {
4039
- if (this.#pendingEnables.get(source) === intentRevision) this.#pendingEnables.delete(source);
4040
- })
4041
- )
4042
- );
4138
+ return this.#act(source, enabled);
4043
4139
  }
4044
4140
  #startScreen(options) {
4045
- let intentRevision = 0;
4046
- return Effect5.sync(() => {
4047
- intentRevision = (this.#intentRevisions.get("screen") ?? 0) + 1;
4048
- this.#intentRevisions.set("screen", intentRevision);
4049
- this.#pendingEnables.set("screen", intentRevision);
4050
- this.#ports?.media.setLocalSourceIntent?.("screen", true);
4051
- }).pipe(
4052
- Effect5.andThen(
4053
- this.#serialize(
4054
- "screen",
4055
- this.#connection.runCommand((ports) => {
4056
- let track = null;
4057
- let prepared = false;
4058
- return Effect5.suspend(() => {
4059
- if (this.#tracks.has("screen") && options === void 0) {
4060
- return Effect5.void;
4061
- }
4062
- return Effect5.acquireUseRelease(
4063
- foreign2(() => this.#selection.getDisplayMedia(displayMediaConstraints(options))).pipe(Effect5.mapError((cause) => captureError(cause, "startScreenShare", "Screen sharing permission was denied"))),
4064
- (stream) => Effect5.sync(() => {
4065
- this.#assertActivePorts(ports, "startScreenShare");
4066
- track = requireDisplayVideoTrack(stream);
4067
- return { stream, track };
4068
- }).pipe(
4069
- Effect5.tap(() => {
4070
- const previous = this.#tracks.get("screen");
4071
- if (!previous) return Effect5.void;
4072
- return foreign2(() => ports.sync.setScreenShareEnabled(false)).pipe(
4073
- Effect5.andThen(this.#clearScreenAudio(ports.media)),
4074
- Effect5.andThen(foreign2(() => ports.media.clearPreparedLocalTrack("screen"))),
4075
- Effect5.tap(
4076
- () => Effect5.sync(() => {
4077
- this.#assertActivePorts(ports, "startScreenShare");
4078
- this.#tracks.delete("screen");
4079
- previous.stop();
4080
- })
4081
- )
4082
- );
4083
- }),
4084
- Effect5.tap(
4085
- ({ stream: captured, track: screenTrack }) => Effect5.sync(() => {
4086
- const audio = captured.getAudioTracks()[0];
4087
- for (const extra of captured.getAudioTracks().slice(1)) extra.stop();
4088
- ports.media.prepareLocalTrack("screen", screenTrack);
4089
- prepared = true;
4090
- this.#tracks.set("screen", screenTrack);
4091
- this.#screenAudioTrack = audio ?? null;
4092
- if (audio) {
4093
- ports.media.prepareLocalTrack("screen_audio", audio);
4094
- audio.addEventListener("ended", () => this.#handleScreenAudioEnded(audio, ports));
4095
- }
4096
- screenTrack.addEventListener("ended", () => this.#handleScreenEnded(screenTrack));
4097
- this.#screenEndedPending = false;
4098
- this.#publish();
4099
- })
4100
- ),
4101
- Effect5.andThen(foreign2(() => ports.sync.setScreenShareEnabled(true))),
4102
- Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, "startScreenShare"))),
4103
- Effect5.asVoid,
4104
- Effect5.catch(
4105
- (cause) => (prepared ? this.#clearScreenAudio(ports.media) : Effect5.void).pipe(
4106
- Effect5.ignore,
4107
- Effect5.andThen(this.#rollbackCapture({ action: "startScreenShare", cause, fallback: Effect5.sync(() => stopStream(stream)), media: ports.media, permissionMessage: "Screen sharing permission was denied", prepared, source: "screen", track }))
4108
- )
4109
- )
4110
- ),
4111
- (stream, exit) => Effect5.sync(() => {
4112
- if (Exit2.isFailure(exit)) stopStream(stream);
4113
- })
4114
- );
4115
- });
4141
+ return this.#act("screen", true, options);
4142
+ }
4143
+ #stopScreen() {
4144
+ return this.#act("screen", false);
4145
+ }
4146
+ #pauseTracks(source) {
4147
+ const track = this.#track(source);
4148
+ if (track) track.enabled = false;
4149
+ if (source === "screen" && this.#screenAudioTrack) this.#screenAudioTrack.enabled = false;
4150
+ }
4151
+ #recordIntent(source, enabled, options) {
4152
+ const state = this.#sources[source];
4153
+ const desired = { enabled, revision: state.desired.revision + 1, options, previousEnabled: state.localEnabled };
4154
+ state.desired = desired;
4155
+ if (!enabled) {
4156
+ state.localEnabled = false;
4157
+ this.#pauseTracks(source);
4158
+ if (source === "screen") {
4159
+ this.#track("screen")?.stop();
4160
+ this.#screenAudioTrack?.stop();
4161
+ }
4162
+ const ports = this.#ports;
4163
+ if (ports) {
4164
+ ports.media.setLocalSourceIntent?.(source, false);
4165
+ const previous = state.pause;
4166
+ const next = this.#beginLocalPause(ports, source);
4167
+ state.pause = previous ? Promise.allSettled([previous, next]).then((results) => {
4168
+ for (const result of results) if (result.status === "rejected") throw result.reason;
4169
+ }) : next;
4170
+ void state.pause.catch(() => void 0);
4171
+ }
4172
+ }
4173
+ return desired;
4174
+ }
4175
+ #act(source, enabled, options) {
4176
+ return Effect5.suspend(() => {
4177
+ const desired = this.#recordIntent(source, enabled, options);
4178
+ return this.#serialize(source, this.#work(source)).pipe(
4179
+ Effect5.andThen(
4180
+ Effect5.suspend(() => {
4181
+ if (enabled && this.#store.getSnapshot().connection.status !== "live") return this.#mediaAction(true, Effect5.void);
4182
+ const result = this.#sources[source].result;
4183
+ return result.revision === desired.revision && result.status === "failed" ? Effect5.fail(result.cause) : Effect5.void;
4116
4184
  })
4117
4185
  )
4118
- )
4119
- ).pipe(
4120
- Effect5.mapError(normalizeClientError),
4186
+ );
4187
+ }).pipe(Effect5.mapError(normalizeMediaError));
4188
+ }
4189
+ #work(source) {
4190
+ return Effect5.gen({ self: this }, function* () {
4191
+ const state = this.#sources[source];
4192
+ while (state.result.revision !== state.desired.revision || state.result.status === "failed" && state.result.cleanup) {
4193
+ const intent = state.desired;
4194
+ yield* this.#runRevision(source, intent);
4195
+ if (state.desired.revision === intent.revision) return;
4196
+ }
4197
+ });
4198
+ }
4199
+ #runRevision(source, intent) {
4200
+ return Effect5.gen({ self: this }, function* () {
4201
+ const state = this.#sources[source];
4202
+ const pause = state.pause;
4203
+ state.pause = null;
4204
+ const paused = yield* Effect5.exit(pause ? foreign2(() => pause) : Effect5.void);
4205
+ if (state.desired.revision !== intent.revision) return;
4206
+ const ports = this.#ports;
4207
+ const cleanup = source === "screen" && !intent.enabled ? yield* Effect5.exit(this.#stopScreenLocally(ports)) : Exit2.succeed(void 0);
4208
+ if (state.desired.revision !== intent.revision) return;
4209
+ if (ports && this.#syncLive) yield* this.#executeRevision(ports, source, intent);
4210
+ else if (intent.enabled) state.result = { revision: intent.revision, status: "failed", cause: new V1SyncError("Media enable requires a live call", "not_live"), cleanup: !intent.previousEnabled };
4211
+ yield* paused;
4212
+ yield* cleanup;
4213
+ });
4214
+ }
4215
+ #executeRevision(ports, source, intent) {
4216
+ return Effect5.gen({ self: this }, function* () {
4217
+ const state = this.#sources[source];
4218
+ if (state.result.revision === intent.revision && state.result.status === "failed") {
4219
+ yield* this.#connection.runCommand((p) => this.#send(p, source, intent, false));
4220
+ if (state.result.revision === intent.revision && state.result.status === "failed") state.result.cleanup = false;
4221
+ return;
4222
+ }
4223
+ const operation = source === "screen" ? this.#prepareScreen(ports, intent) : this.#prepareDevice(ports, source, intent);
4224
+ const result = yield* Effect5.exit(this.#connection.runCommand(() => operation));
4225
+ if (state.desired.revision !== intent.revision) return;
4226
+ if (Exit2.isSuccess(result)) {
4227
+ state.result = { revision: intent.revision, status: "confirmed" };
4228
+ state.localEnabled = state.forcedRevision === intent.revision ? false : intent.enabled;
4229
+ } else {
4230
+ const cause = yield* Effect5.flip(result);
4231
+ yield* this.#failRevision(ports, source, intent, cause);
4232
+ }
4233
+ });
4234
+ }
4235
+ #failRevision(ports, source, intent, cause) {
4236
+ return Effect5.sync(() => {
4237
+ const state = this.#sources[source];
4238
+ if (!intent.enabled && ports.sync.getSnapshot().connection.phase !== "live") return;
4239
+ const previous = intent.previousEnabled;
4240
+ if (state.desired.revision === intent.revision) state.result = { revision: intent.revision, status: "failed", cause, cleanup: !intent.enabled || !previous };
4241
+ });
4242
+ }
4243
+ #prepareDevice(ports, source, intent) {
4244
+ if (!intent.enabled) return this.#send(ports, source, intent, false);
4245
+ let captured = null;
4246
+ let prepared = false;
4247
+ const action = source === "microphone" ? "setMicrophoneEnabled" : "setCameraEnabled";
4248
+ return this.#mediaAction(
4249
+ true,
4250
+ Effect5.suspend(() => {
4251
+ this.#assertCurrentIntent(source, intent.revision);
4252
+ ports.media.setLocalSourceIntent?.(source, true);
4253
+ const retire = source === "microphone" ? this.#retireEndedMicrophone(ports.media) : Effect5.void;
4254
+ const acquire = Effect5.suspend(() => {
4255
+ this.#assertActivePorts(ports, action);
4256
+ this.#assertCurrentIntent(source, intent.revision);
4257
+ return source === "camera" && !intent.previousEnabled || !this.#hasTrack(source) ? this.#captureForPublish(
4258
+ source,
4259
+ ports.media,
4260
+ (track) => Effect5.try({
4261
+ try: () => {
4262
+ captured = track;
4263
+ this.#assertActivePorts(ports, action);
4264
+ this.#assertCurrentIntent(source, intent.revision);
4265
+ this.#setTrack(source, track);
4266
+ ports.media.prepareLocalTrack(source, track);
4267
+ prepared = true;
4268
+ this.#publish();
4269
+ },
4270
+ catch: (cause) => cause
4271
+ })
4272
+ ) : Effect5.void;
4273
+ });
4274
+ return retire.pipe(Effect5.andThen(acquire), Effect5.andThen(this.#send(ports, source, intent, true)));
4275
+ })
4276
+ ).pipe(Effect5.catch((cause) => this.#rollbackCapture({ action, cause, fallback: Effect5.void, media: ports.media, permissionMessage: `${source} permission was denied`, prepared, source, track: captured })));
4277
+ }
4278
+ #stopScreenLocally(ports) {
4279
+ const track = this.#track("screen");
4280
+ const clearVideo = ports ? foreign2(() => ports.media.clearPreparedLocalTrack("screen")) : Effect5.void;
4281
+ const clear = ports ? this.#clearScreenAudio(ports.media).pipe(Effect5.matchEffect({ onFailure: (cause) => clearVideo.pipe(Effect5.andThen(Effect5.fail(cause))), onSuccess: () => clearVideo })) : Effect5.void;
4282
+ return clear.pipe(
4121
4283
  Effect5.ensuring(
4122
4284
  Effect5.sync(() => {
4123
- if (this.#pendingEnables.get("screen") === intentRevision) this.#pendingEnables.delete("screen");
4285
+ if (this.#track("screen") === track) this.#deleteTrack("screen");
4286
+ track?.stop();
4287
+ this.#publish();
4124
4288
  })
4125
4289
  )
4126
4290
  );
4127
4291
  }
4128
- #stopScreen() {
4129
- let intentRevision = 0;
4130
- let earlyPause = null;
4131
- const localPause = Effect5.suspend(() => {
4132
- intentRevision = (this.#intentRevisions.get("screen") ?? 0) + 1;
4133
- this.#intentRevisions.set("screen", intentRevision);
4134
- this.#ports?.media.setLocalSourceIntent?.("screen", false);
4135
- if (this.#ports) earlyPause = this.#beginLocalPause(this.#ports, "screen");
4136
- return Effect5.void;
4292
+ #prepareScreen(ports, intent) {
4293
+ if (!intent.enabled) return this.#send(ports, "screen", intent, false);
4294
+ return this.#mediaAction(
4295
+ true,
4296
+ Effect5.suspend(() => {
4297
+ this.#assertCurrentIntent("screen", intent.revision);
4298
+ ports.media.setLocalSourceIntent?.("screen", true);
4299
+ if (this.#hasTrack("screen") && !intent.options) return this.#send(ports, "screen", intent, true);
4300
+ let track = null;
4301
+ let prepared = false;
4302
+ return this.#captureStream(
4303
+ () => this.#selection.getDisplayMedia(displayMediaConstraints(intent.options)),
4304
+ (stream) => Effect5.gen({ self: this }, function* () {
4305
+ this.#assertActivePorts(ports, "startScreenShare");
4306
+ this.#assertCurrentIntent("screen", intent.revision);
4307
+ track = requireDisplayVideoTrack(stream);
4308
+ const previous = this.#track("screen");
4309
+ if (previous) {
4310
+ yield* this.#send(ports, "screen", intent, false);
4311
+ yield* this.#clearScreenAudio(ports.media);
4312
+ yield* foreign2(() => ports.media.clearPreparedLocalTrack("screen"));
4313
+ this.#deleteTrack("screen");
4314
+ previous.stop();
4315
+ }
4316
+ this.#assertActivePorts(ports, "startScreenShare");
4317
+ this.#assertCurrentIntent("screen", intent.revision);
4318
+ const audio = stream.getAudioTracks()[0];
4319
+ for (const extra of stream.getAudioTracks().slice(1)) extra.stop();
4320
+ ports.media.prepareLocalTrack("screen", track);
4321
+ prepared = true;
4322
+ this.#setTrack("screen", track);
4323
+ this.#screenAudioTrack = audio ?? null;
4324
+ if (audio) {
4325
+ ports.media.prepareLocalTrack("screen_audio", audio);
4326
+ audio.addEventListener("ended", () => this.#handleScreenAudioEnded(audio, ports));
4327
+ }
4328
+ track.addEventListener("ended", () => this.#handleScreenEnded(track ?? void 0));
4329
+ this.#publish();
4330
+ yield* this.#send(ports, "screen", intent, true);
4331
+ }).pipe(
4332
+ Effect5.catch(
4333
+ (cause) => (prepared ? this.#clearScreenAudio(ports.media) : Effect5.void).pipe(
4334
+ Effect5.ignore,
4335
+ Effect5.andThen(this.#rollbackCapture({ action: "startScreenShare", cause, fallback: Effect5.sync(() => stopStream(stream)), media: ports.media, permissionMessage: "Screen sharing permission was denied", prepared, source: "screen", track }))
4336
+ )
4337
+ )
4338
+ ),
4339
+ (cause) => captureError(cause, "startScreenShare", "Screen sharing permission was denied")
4340
+ );
4341
+ })
4342
+ );
4343
+ }
4344
+ #assertCurrentIntent(source, revision) {
4345
+ if (this.#sources[source].desired.revision !== revision) throw cancelledCaptureError();
4346
+ }
4347
+ #resetObservations() {
4348
+ for (const source of MEDIA_SOURCES) {
4349
+ const state = this.#sources[source];
4350
+ state.result = { revision: state.desired.revision, status: "confirmed" };
4351
+ state.pause = null;
4352
+ state.serverSequence = -1;
4353
+ state.forcedRevision = null;
4354
+ }
4355
+ }
4356
+ #pauseInitialTracks() {
4357
+ for (const source of ["microphone", "camera"]) {
4358
+ const enabled = this.#sources[source].desired.enabled;
4359
+ const track = this.#track(source);
4360
+ if (track && !enabled) track.enabled = false;
4361
+ this.#sources[source].localEnabled = !!track && enabled;
4362
+ }
4363
+ }
4364
+ startInitial(media, stream) {
4365
+ const publish = foreign2(async () => {
4366
+ const revisions = { microphone: this.#sources.microphone.initialRevision, camera: this.#sources.camera.initialRevision };
4367
+ const tracks = stream.getTracks().filter((track) => {
4368
+ const source = track.kind === "audio" ? "microphone" : "camera";
4369
+ const state = this.#sources[source];
4370
+ track.enabled = state.desired.enabled;
4371
+ if (!state.desired.enabled) {
4372
+ this.#deleteTrack(source);
4373
+ if (source === "microphone") track.stop();
4374
+ }
4375
+ return state.desired.enabled;
4376
+ });
4377
+ for (const source of ["microphone", "camera"]) this.#sources[source].sentRevision = revisions[source];
4378
+ await media.start(streamFromTracks(tracks));
4379
+ for (const source of ["microphone", "camera"]) {
4380
+ const state = this.#sources[source];
4381
+ if (state.desired.revision === revisions[source]) state.result = { revision: revisions[source], status: "confirmed" };
4382
+ }
4137
4383
  });
4138
- return localPause.pipe(
4139
- Effect5.andThen(
4140
- this.#serialize(
4141
- "screen",
4142
- this.#connection.runCommand(
4143
- (ports) => Effect5.suspend(() => {
4144
- const track = this.#tracks.get("screen");
4145
- let paused = false;
4146
- return (earlyPause ? foreign2(() => earlyPause) : this.#localTarget(ports, "screen", false)).pipe(
4147
- Effect5.tap(
4148
- () => Effect5.sync(() => {
4149
- paused = true;
4150
- })
4151
- ),
4152
- Effect5.andThen(foreign2(() => ports.sync.setScreenShareEnabled(false))),
4153
- Effect5.tap(() => Effect5.sync(() => this.#assertActivePorts(ports, "stopScreenShare"))),
4154
- Effect5.andThen(this.#clearScreenAudio(ports.media)),
4155
- Effect5.andThen(foreign2(() => ports.media.clearPreparedLocalTrack("screen"))),
4156
- Effect5.tap(
4157
- () => Effect5.sync(() => {
4158
- this.#assertActivePorts(ports, "stopScreenShare");
4159
- if (this.#tracks.get("screen") === track) this.#tracks.delete("screen");
4160
- track?.stop();
4161
- this.#screenEndedPending = false;
4162
- this.#publish();
4163
- })
4164
- ),
4165
- Effect5.asVoid,
4166
- Effect5.catch(
4167
- (cause) => paused && !isSyncRejection(cause) || this.#intentRevisions.get("screen") !== intentRevision ? Effect5.fail(cause) : Effect5.sync(() => ports.media.setLocalSourceIntent?.("screen", true)).pipe(
4168
- Effect5.andThen(this.#localTarget(ports, "screen", true)),
4169
- Effect5.catch(() => Effect5.void),
4170
- Effect5.andThen(Effect5.fail(cause))
4171
- )
4172
- )
4173
- );
4174
- })
4175
- )
4176
- )
4177
- )
4178
- ).pipe(Effect5.mapError(normalizeClientError));
4384
+ return this.#serialize("microphone", this.#serialize("camera", publish));
4385
+ }
4386
+ #send(ports, source, intent, enabled) {
4387
+ return this.#mediaAction(
4388
+ enabled,
4389
+ foreign2(async () => {
4390
+ this.#assertCurrentIntent(source, intent.revision);
4391
+ this.#assertActivePorts(ports, source === "screen" ? "startScreenShare" : source === "camera" ? "setCameraEnabled" : "setMicrophoneEnabled");
4392
+ const state = this.#sources[source];
4393
+ state.sentRevision = intent.revision;
4394
+ await (source === "microphone" ? ports.sync.setMicrophoneEnabled(enabled) : source === "camera" ? ports.sync.setCameraEnabled(enabled) : ports.sync.setScreenShareEnabled(enabled));
4395
+ this.#assertActivePorts(ports, source === "screen" ? "startScreenShare" : source === "camera" ? "setCameraEnabled" : "setMicrophoneEnabled");
4396
+ })
4397
+ );
4398
+ }
4399
+ #reconcileIntents() {
4400
+ const live = this.#connection.getSnapshot().state === "live" && this.#ports?.sync.getSnapshot().connection.phase === "live";
4401
+ const resumed = live && !this.#syncLive;
4402
+ this.#syncLive = live;
4403
+ if (!resumed) return;
4404
+ for (const source of MEDIA_SOURCES) this.#fork(this.#serialize(source, this.#work(source)).pipe(Effect5.ignore));
4179
4405
  }
4180
4406
  #clearScreenAudio(media) {
4181
4407
  return foreign2(() => media.clearPreparedLocalTrack("screen_audio")).pipe(
@@ -4215,26 +4441,241 @@ var MediaControllerRuntime = class {
4215
4441
  )
4216
4442
  );
4217
4443
  }
4218
- #captureSource(source) {
4219
- return foreign2(() => this.#selection.getUserMedia({ audio: source === "microphone", video: source === "camera" })).pipe(
4220
- Effect5.mapError((cause) => captureError(cause, source === "microphone" ? "setMicrophoneEnabled" : "setCameraEnabled", `${source} permission was denied`)),
4221
- Effect5.tap(() => this.#refreshDevices().pipe(Effect5.ignore)),
4222
- Effect5.flatMap(
4223
- (stream) => Effect5.try({
4224
- try: () => {
4225
- const track = stream.getTracks().find((candidate) => candidate.kind === (source === "microphone" ? "audio" : "video"));
4226
- if (!track) {
4227
- stopStream(stream);
4228
- throw new TypeError(`Media capture did not return a ${source} track`);
4229
- }
4230
- for (const candidate of stream.getTracks()) if (candidate !== track) candidate.stop();
4231
- return track;
4232
- },
4233
- catch: (cause) => cause
4444
+ #transitionCamera(next) {
4445
+ const previous = this.#camera;
4446
+ const sameOwner = "owner" in previous && "owner" in next && previous.owner === next.owner;
4447
+ if (previous.status === "live") previous.track.removeEventListener("ended", previous.onEnded);
4448
+ this.#camera = next;
4449
+ if (!sameOwner && "owner" in previous) {
4450
+ previous.owner.cancel();
4451
+ if (previous.status === "live") previous.track.stop();
4452
+ }
4453
+ if (next.status === "live") next.track.addEventListener("ended", next.onEnded, { once: true });
4454
+ this.#publish();
4455
+ this.#retireCameraPublication(previous, next);
4456
+ }
4457
+ #retireCameraPublication(previous, next) {
4458
+ if (previous.status !== "live" || previous.publication === null || previous.publication === "initial") return;
4459
+ if (next.status === "live" && next.track === previous.track && next.publication !== null) return;
4460
+ const media = previous.publication;
4461
+ void Promise.resolve().then(() => media.clearPreparedLocalTrack("camera", { stopTrack: false })).then(() => this.#enablePrivateCamera(previous.owner)).catch((cause) => Effect5.runSync(Effect5.logWarning("Camera publication cleanup failed", cause)));
4462
+ }
4463
+ #enablePrivateCamera(owner) {
4464
+ const state = this.#camera;
4465
+ if (state.status === "live" && state.owner === owner && state.publication === null) state.track.enabled = true;
4466
+ }
4467
+ #track(source) {
4468
+ if (source !== "camera") return this.#tracks.get(source);
4469
+ return this.#camera.status === "live" && this.#camera.publication !== null && this.#camera.track.readyState === "live" ? this.#camera.track : void 0;
4470
+ }
4471
+ #hasTrack(source) {
4472
+ return this.#track(source) !== void 0;
4473
+ }
4474
+ #localTracks() {
4475
+ const tracks = new Map(this.#tracks);
4476
+ const camera = this.#track("camera");
4477
+ if (camera) tracks.set("camera", camera);
4478
+ return tracks;
4479
+ }
4480
+ #setTrack(source, track) {
4481
+ if (source !== "camera") {
4482
+ this.#tracks.set(source, track);
4483
+ return;
4484
+ }
4485
+ const state = this.#camera;
4486
+ if (!("owner" in state)) throw cancelledCaptureError();
4487
+ if (state.status === "live" && state.track !== track) throw cancelledCaptureError();
4488
+ const onEnded = () => {
4489
+ if (this.#camera.status === "live" && this.#camera.track === track) this.#transitionCamera({ status: "ended" });
4490
+ };
4491
+ this.#transitionCamera({ status: "live", owner: state.owner, track, publication: this.#ports?.media ?? "initial", onEnded });
4492
+ }
4493
+ #deleteTrack(source) {
4494
+ if (source !== "camera") {
4495
+ this.#tracks.delete(source);
4496
+ return;
4497
+ }
4498
+ const state = this.#camera;
4499
+ if (state.status !== "live") return;
4500
+ if (state.owner.references > 0 && state.track.readyState === "live") {
4501
+ this.#transitionCamera({ ...state, publication: null });
4502
+ } else this.#transitionCamera({ status: "ended" });
4503
+ }
4504
+ #clearTracks() {
4505
+ this.#tracks.clear();
4506
+ this.#transitionCamera({ status: "idle" });
4507
+ }
4508
+ #cameraSource(purpose, acquire = (signal) => this.#captureSource("camera", signal)) {
4509
+ return Effect5.suspend(() => {
4510
+ this.#discardEndedCamera();
4511
+ const current = this.#camera;
4512
+ if (current.status === "live") return this.#liveCameraSource(current, purpose);
4513
+ if (current.status === "capturing") return this.#borrowCamera(current.owner);
4514
+ return this.#borrowCamera(this.#beginCameraCapture(acquire));
4515
+ });
4516
+ }
4517
+ #discardEndedCamera() {
4518
+ if (this.#camera.status === "live" && this.#camera.track.readyState !== "live") this.#transitionCamera({ status: "ended" });
4519
+ }
4520
+ #retireEndedMicrophone(media) {
4521
+ const track = this.#tracks.get("microphone");
4522
+ if (track?.readyState !== "ended") return Effect5.void;
4523
+ return foreign2(() => media.clearPreparedLocalTrack("microphone", { stopTrack: false })).pipe(
4524
+ Effect5.andThen(
4525
+ Effect5.sync(() => {
4526
+ if (this.#tracks.get("microphone") === track) this.#tracks.delete("microphone");
4527
+ })
4528
+ )
4529
+ );
4530
+ }
4531
+ #liveCameraSource(current, purpose) {
4532
+ if (current.publication !== null && !current.track.enabled && purpose === "preview") {
4533
+ const track = current.track.clone();
4534
+ track.enabled = true;
4535
+ const release = () => {
4536
+ this.#pendingCaptures.delete(release);
4537
+ track.stop();
4538
+ };
4539
+ this.#pendingCaptures.add(release);
4540
+ return Effect5.succeed({ track, release });
4541
+ }
4542
+ return this.#borrowCamera(current.owner);
4543
+ }
4544
+ #beginCameraCapture(acquire) {
4545
+ const abort = new AbortController();
4546
+ const owner = {
4547
+ result: Deferred2.makeUnsafe(),
4548
+ references: 0,
4549
+ cancel: () => {
4550
+ abort.abort();
4551
+ Deferred2.doneUnsafe(owner.result, Effect5.fail(cancelledCaptureError()));
4552
+ }
4553
+ };
4554
+ this.#transitionCamera({ status: "capturing", owner });
4555
+ this.#fork(
4556
+ acquire(abort.signal).pipe(
4557
+ Effect5.tap(
4558
+ (track) => Effect5.try({
4559
+ try: () => {
4560
+ const state = this.#camera;
4561
+ if (abort.signal.aborted || !("owner" in state) || state.owner !== owner) {
4562
+ track.stop();
4563
+ throw cancelledCaptureError();
4564
+ }
4565
+ const onEnded = () => {
4566
+ if (this.#camera.status === "live" && this.#camera.owner === owner) this.#transitionCamera({ status: "ended" });
4567
+ };
4568
+ this.#transitionCamera({ status: "live", owner, track, publication: state.status === "live" ? state.publication : null, onEnded });
4569
+ if (track.readyState !== "live") {
4570
+ this.#transitionCamera({ status: "ended" });
4571
+ throw cancelledCaptureError();
4572
+ }
4573
+ },
4574
+ catch: (cause) => cause
4575
+ })
4576
+ ),
4577
+ Effect5.onExit((exit) => Deferred2.done(owner.result, exit)),
4578
+ Effect5.ignore
4579
+ )
4580
+ );
4581
+ return owner;
4582
+ }
4583
+ #borrowCamera(owner) {
4584
+ owner.references++;
4585
+ let released = false;
4586
+ const release = () => {
4587
+ if (released) return;
4588
+ released = true;
4589
+ owner.references--;
4590
+ const current = this.#camera;
4591
+ if (owner.references === 0 && "owner" in current && current.owner === owner && (current.status !== "live" || current.publication === null)) this.#transitionCamera({ status: "ended" });
4592
+ };
4593
+ return Deferred2.await(owner.result).pipe(
4594
+ Effect5.flatMap((track) => track.readyState === "live" ? Effect5.succeed({ track, release }) : Effect5.fail(cancelledCaptureError())),
4595
+ Effect5.onExit(
4596
+ (exit) => Effect5.sync(() => {
4597
+ if (Exit2.isFailure(exit)) release();
4234
4598
  })
4235
4599
  )
4236
4600
  );
4237
4601
  }
4602
+ #useCamera(use, acquire) {
4603
+ return Effect5.suspend(() => {
4604
+ let preview;
4605
+ return this.#cameraSource("publication", acquire).pipe(
4606
+ Effect5.tap(
4607
+ (value) => Effect5.sync(() => {
4608
+ preview = value;
4609
+ })
4610
+ ),
4611
+ Effect5.flatMap((value) => use(value.track)),
4612
+ Effect5.ensuring(Effect5.sync(() => preview?.release()))
4613
+ );
4614
+ });
4615
+ }
4616
+ #captureForPublish(source, media, prepare) {
4617
+ return source === "camera" ? foreign2(() => media.clearPreparedLocalTrack("camera", { stopTrack: false })).pipe(Effect5.retry({ times: 1 }), Effect5.andThen(this.#useCamera(prepare))) : this.#captureSource(source).pipe(Effect5.flatMap(prepare));
4618
+ }
4619
+ #captureStream(operation, use, onCaptureError = (cause) => cause, signal) {
4620
+ return Effect5.suspend(() => {
4621
+ let cancelled = false;
4622
+ let captured = null;
4623
+ const cancel = () => {
4624
+ cancelled = true;
4625
+ stopStream(captured);
4626
+ };
4627
+ this.#pendingCaptures.add(cancel);
4628
+ signal?.addEventListener("abort", cancel, { once: true });
4629
+ if (signal?.aborted) cancel();
4630
+ return foreign2(async () => {
4631
+ if (cancelled) throw cancelledCaptureError();
4632
+ const stream = await operation();
4633
+ captured = stream;
4634
+ if (cancelled) {
4635
+ stopStream(stream);
4636
+ throw cancelledCaptureError();
4637
+ }
4638
+ return stream;
4639
+ }).pipe(
4640
+ Effect5.mapError(onCaptureError),
4641
+ Effect5.flatMap(use),
4642
+ Effect5.tap(() => cancelled ? Effect5.fail(cancelledCaptureError()) : Effect5.void),
4643
+ Effect5.onExit(
4644
+ (exit) => Effect5.sync(() => {
4645
+ this.#pendingCaptures.delete(cancel);
4646
+ signal?.removeEventListener("abort", cancel);
4647
+ if (Exit2.isFailure(exit)) cancel();
4648
+ })
4649
+ )
4650
+ );
4651
+ });
4652
+ }
4653
+ #cancelCaptures() {
4654
+ this.#transitionCamera({ status: "ended" });
4655
+ for (const cancel of this.#pendingCaptures) cancel();
4656
+ this.#pendingCaptures.clear();
4657
+ }
4658
+ #captureSource(source, signal) {
4659
+ return this.#captureStream(
4660
+ () => this.#selection.getUserMedia({ audio: source === "microphone", video: source === "camera" }),
4661
+ (stream) => this.#refreshDevices().pipe(
4662
+ Effect5.ignore,
4663
+ Effect5.andThen(
4664
+ Effect5.try({
4665
+ try: () => {
4666
+ const track = stream.getTracks().find((candidate) => candidate.kind === (source === "microphone" ? "audio" : "video"));
4667
+ if (!track) throw new TypeError(`Media capture did not return a ${source} track`);
4668
+ for (const candidate of stream.getTracks()) if (candidate !== track) candidate.stop();
4669
+ return track;
4670
+ },
4671
+ catch: (cause) => cause
4672
+ })
4673
+ )
4674
+ ),
4675
+ (cause) => captureError(cause, source === "microphone" ? "setMicrophoneEnabled" : "setCameraEnabled", `${source} permission was denied`),
4676
+ signal
4677
+ );
4678
+ }
4238
4679
  #refreshDevices() {
4239
4680
  return foreign2(() => this.#selection.enumerateDevices()).pipe(
4240
4681
  Effect5.tap(
@@ -4248,11 +4689,15 @@ var MediaControllerRuntime = class {
4248
4689
  );
4249
4690
  }
4250
4691
  #discardCaptured(source, track, media, prepared) {
4692
+ if (source === "camera")
4693
+ return Effect5.sync(() => {
4694
+ if (this.#track(source) === track) this.#deleteTrack(source);
4695
+ });
4251
4696
  const clear = prepared ? foreign2(() => media.clearPreparedLocalTrack(source)).pipe(Effect5.ignore) : Effect5.void;
4252
4697
  return clear.pipe(
4253
4698
  Effect5.andThen(
4254
4699
  Effect5.sync(() => {
4255
- if (this.#tracks.get(source) === track) this.#tracks.delete(source);
4700
+ if (this.#track(source) === track) this.#deleteTrack(source);
4256
4701
  track.stop();
4257
4702
  })
4258
4703
  )
@@ -4260,7 +4705,6 @@ var MediaControllerRuntime = class {
4260
4705
  }
4261
4706
  #rollbackCapture(input) {
4262
4707
  if (isAccessInvalid2(input.cause)) return Effect5.fail(input.cause);
4263
- input.restoreIntent?.();
4264
4708
  const rollback = input.track ? this.#discardCaptured(input.source, input.track, input.media, input.prepared) : input.fallback;
4265
4709
  const captureWasUnavailable = input.action === "startScreenShare" && !input.prepared && input.track === null;
4266
4710
  const failure2 = isPermissionDenied(input.cause) || captureWasUnavailable ? captureError(input.cause, input.action, input.permissionMessage) : input.cause;
@@ -4270,21 +4714,33 @@ var MediaControllerRuntime = class {
4270
4714
  if (this.#connection.getSnapshot().state === "live" && this.#ports?.sync === ports.sync && this.#ports.media === ports.media) return;
4271
4715
  throw new ConnectionError({ code: "invalid_state", action, recoverable: false, message: `${action} belongs to an inactive connection` });
4272
4716
  }
4717
+ #pausePublication(ports, source, revision) {
4718
+ const state = this.#sources[source];
4719
+ if (state.desired.revision !== revision || ports.sync.getSnapshot().control?.status === "ended") return;
4720
+ state.forcedRevision = revision;
4721
+ state.localEnabled = false;
4722
+ this.#pauseTracks(source);
4723
+ ports.media.setLocalSourceIntent?.(source, false);
4724
+ if (ports.media.closeForcedLocalPublication) this.#fork(foreign2(() => ports.media.closeForcedLocalPublication(source)).pipe(Effect5.ignore));
4725
+ this.#publish();
4726
+ }
4273
4727
  #bind(ports) {
4274
- this.#unsubscribeMedia?.();
4275
- this.#unsubscribeRequests?.();
4276
- this.#unsubscribeSync?.();
4728
+ if (ports && this.#ports?.sync === ports.sync && this.#ports.media === ports.media) return;
4729
+ for (const unsubscribe of [this.#unsubscribeMedia, this.#unsubscribeRequests, this.#unsubscribePause, this.#unsubscribeSync]) unsubscribe?.();
4277
4730
  this.#unsubscribeMedia = null;
4278
4731
  this.#unsubscribeRequests = null;
4732
+ this.#unsubscribePause = null;
4279
4733
  this.#unsubscribeSync = null;
4280
- if (this.#ports?.media !== ports?.media) this.#observedPublicationIds.clear();
4281
4734
  this.#ports = ports;
4735
+ this.#syncLive = false;
4736
+ this.#bindCamera(ports);
4282
4737
  if (!ports) {
4738
+ this.#resetObservations();
4739
+ this.#cancelCaptures();
4283
4740
  this.#screenAudioTrack?.stop();
4284
4741
  this.#screenAudioTrack = null;
4285
4742
  for (const track of this.#tracks.values()) track.stop();
4286
- this.#tracks.clear();
4287
- this.#screenEndedPending = false;
4743
+ this.#clearTracks();
4288
4744
  this.#clearRequests();
4289
4745
  this.#publish();
4290
4746
  return;
@@ -4293,56 +4749,63 @@ var MediaControllerRuntime = class {
4293
4749
  this.#applyRemoteMediaState(ports, ports.sync.getSnapshot());
4294
4750
  this.#publish();
4295
4751
  });
4752
+ this.#unsubscribePause = this.#subscribePauses(ports);
4296
4753
  this.#unsubscribeRequests = ports.sync.onDirectedRequest((request) => this.#request(request));
4297
- this.#unsubscribeSync = ports.sync.subscribe((snapshot) => {
4298
- this.#removeRequestsFromMissingParticipants(snapshot);
4299
- this.#applyRemoteMediaState(ports, snapshot);
4300
- this.#applyForcedMediaState(ports, snapshot);
4301
- });
4754
+ this.#unsubscribeSync = this.#subscribeSync(ports);
4302
4755
  this.#applyRemoteMediaState(ports, ports.sync.getSnapshot());
4303
- this.#applyForcedMediaState(ports, ports.sync.getSnapshot());
4304
4756
  this.#publish();
4305
4757
  }
4306
- #applyRemoteMediaState(ports, snapshot) {
4307
- for (const publication of ports.media.getSnapshot().remoteTracks) {
4308
- publication.track.enabled = snapshot.media?.items.some((item) => item.participantId === publication.participantId && item.source === (publication.source === "screen_audio" ? "screen" : publication.source) && item.enabled) ?? false;
4309
- }
4758
+ #subscribePauses(ports) {
4759
+ return ports.sync.onMediaPause?.((pause) => {
4760
+ const state = this.#sources[pause.source];
4761
+ if (pause.revision <= state.serverSequence) return;
4762
+ state.serverSequence = pause.revision;
4763
+ this.#pausePublication(ports, pause.source, state.sentRevision);
4764
+ }) ?? null;
4310
4765
  }
4311
- #applyForcedMediaState(ports, snapshot) {
4312
- if (!canApplyForcedMediaState(snapshot)) return;
4313
- for (const source of MEDIA_SOURCES) {
4314
- const projected = snapshot.media.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
4315
- if (projected?.publicationId) {
4316
- this.#observedPublicationIds.set(source, projected.publicationId);
4317
- continue;
4318
- }
4319
- const previousPublicationId = this.#observedPublicationIds.get(source);
4320
- if (!this.#observedPublicationIds.delete(source)) continue;
4321
- const local = ports.media.getSnapshot().localTracks.find((publication) => publication.source === source);
4322
- if (local && local.publicationId !== previousPublicationId) continue;
4323
- this.#pauseForcedSource(ports, source);
4766
+ #rememberPublications(snapshot) {
4767
+ const publications = /* @__PURE__ */ new Map();
4768
+ const own = (snapshot.media?.items ?? []).filter((item) => item.participantId === snapshot.participantId && item.enabled && item.publicationId !== null);
4769
+ for (const item of own) {
4770
+ if (item.source === "screen_audio" || item.publicationId === null) continue;
4771
+ const state = this.#sources[item.source];
4772
+ if (state.localEnabled && state.sentRevision === state.desired.revision) publications.set(item.source, { revision: state.sentRevision, publicationId: item.publicationId });
4324
4773
  }
4774
+ return publications;
4325
4775
  }
4326
- #pauseForcedSource(ports, source) {
4327
- const pendingEnable = this.#hasPendingEnable(source);
4328
- if (!pendingEnable) {
4329
- this.#clearForcedIntent(source);
4776
+ #pauseLostPublications(ports, snapshot, publications) {
4777
+ for (const [source, previous] of publications) {
4778
+ const current = snapshot.media?.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
4779
+ if (!current || !current.enabled && (current.publicationId === previous.publicationId || current.publicationId === null)) this.#pausePublication(ports, source, previous.revision);
4330
4780
  }
4331
- const track = this.#tracks.get(source);
4332
- if (track) track.enabled = false;
4333
- ports.media.setLocalSourceIntent?.(source, false);
4334
- if (ports.media.closeForcedLocalPublication) void ports.media.closeForcedLocalPublication(source).catch(() => void 0);
4335
- if (pendingEnable) ports.media.setLocalSourceIntent?.(source, true);
4336
- this.#publish();
4337
4781
  }
4338
- #clearForcedIntent(source) {
4339
- this.#intentRevisions.set(source, (this.#intentRevisions.get(source) ?? 0) + 1);
4340
- if (source !== "screen") this.#intent[source] = false;
4341
- else if (this.#screenAudioTrack) this.#screenAudioTrack.enabled = false;
4782
+ #subscribeSync(ports) {
4783
+ let recovering = /* @__PURE__ */ new Map();
4784
+ let lastLive = ports.sync.getSnapshot();
4785
+ let wasLive = lastLive.connection.phase === "live";
4786
+ return ports.sync.subscribe((snapshot) => {
4787
+ const live = snapshot.connection.phase === "live";
4788
+ if (wasLive && !live) recovering = this.#rememberPublications(lastLive);
4789
+ if (live && !wasLive) {
4790
+ this.#pauseLostPublications(ports, snapshot, recovering);
4791
+ recovering.clear();
4792
+ }
4793
+ if (live) lastLive = snapshot;
4794
+ wasLive = live;
4795
+ this.#removeRequestsFromMissingParticipants(snapshot);
4796
+ this.#applyRemoteMediaState(ports, snapshot);
4797
+ this.#reconcileIntents();
4798
+ });
4799
+ }
4800
+ #bindCamera(ports) {
4801
+ if (!ports) return;
4802
+ const state = this.#camera;
4803
+ if (state.status === "live" && state.publication === "initial") this.#transitionCamera({ ...state, publication: ports.media });
4342
4804
  }
4343
- #hasPendingEnable(source) {
4344
- const revision = this.#pendingEnables.get(source);
4345
- return revision !== void 0 && revision === this.#intentRevisions.get(source);
4805
+ #applyRemoteMediaState(ports, snapshot) {
4806
+ for (const publication of ports.media.getSnapshot().remoteTracks) {
4807
+ publication.track.enabled = snapshot.media?.items.some((item) => item.participantId === publication.participantId && item.source === (publication.source === "screen_audio" ? "screen" : publication.source) && item.enabled) ?? false;
4808
+ }
4346
4809
  }
4347
4810
  #request(request) {
4348
4811
  if (request.expires_at_ms <= this.#now()) return;
@@ -4387,10 +4850,7 @@ var MediaControllerRuntime = class {
4387
4850
  this.#requests = Object.freeze([]);
4388
4851
  }
4389
4852
  #handleConnectionChange() {
4390
- if (this.#screenEndedPending && this.#connection.getSnapshot().state === "live") {
4391
- this.#screenEndedPending = false;
4392
- this.#fork(this.#stopScreen().pipe(Effect5.ignore));
4393
- }
4853
+ this.#reconcileIntents();
4394
4854
  this.#publish();
4395
4855
  }
4396
4856
  #handleScreenAudioEnded(track, ports) {
@@ -4404,12 +4864,8 @@ var MediaControllerRuntime = class {
4404
4864
  ).pipe(Effect5.ignore)
4405
4865
  );
4406
4866
  }
4407
- #handleScreenEnded(track = this.#tracks.get("screen")) {
4408
- if (!track || this.#tracks.get("screen") !== track) return;
4409
- if (this.#connection.getSnapshot().state === "reconnecting") {
4410
- this.#screenEndedPending = true;
4411
- return;
4412
- }
4867
+ #handleScreenEnded(track = this.#track("screen")) {
4868
+ if (!track || this.#track("screen") !== track) return;
4413
4869
  this.#fork(this.#stopScreen().pipe(Effect5.ignore));
4414
4870
  }
4415
4871
  #publish() {
@@ -4417,9 +4873,9 @@ var MediaControllerRuntime = class {
4417
4873
  const snapshot = this.#ports?.media.getSnapshot();
4418
4874
  const state = this.#connection.getSnapshot().state;
4419
4875
  const local = Object.freeze({
4420
- microphone: localMedia("microphone", this.#tracks, snapshot, this.#intent.microphone, state, this.#pendingSources.has("microphone")),
4421
- camera: localMedia("camera", this.#tracks, snapshot, this.#intent.camera, state, this.#pendingSources.has("camera")),
4422
- screen: localMedia("screen", this.#tracks, snapshot, false, state, this.#pendingSources.has("screen"))
4876
+ microphone: localMedia("microphone", this.#localTracks(), snapshot, this.#sources.microphone.localEnabled, state, this.#pendingSources.has("microphone")),
4877
+ camera: localMedia("camera", this.#localTracks(), snapshot, this.#sources.camera.localEnabled, state, this.#pendingSources.has("camera")),
4878
+ screen: localMedia("screen", this.#localTracks(), snapshot, false, state, this.#pendingSources.has("screen"))
4423
4879
  });
4424
4880
  const remote = Object.freeze((snapshot?.remoteTracks ?? []).map((track) => Object.freeze({ participantId: track.participantId, source: track.source, publicationId: track.publicationId, track: track.track })));
4425
4881
  const media = Object.freeze({
@@ -4436,6 +4892,9 @@ var MediaControllerRuntime = class {
4436
4892
  if (!sameMediaSlice(current.media, media)) this.#store.updateMedia(media);
4437
4893
  }
4438
4894
  };
4895
+ function cancelledCaptureError() {
4896
+ return new ConnectionError({ code: "invalid_state", action: null, recoverable: false, message: "Media capture was cancelled before its result could be used" });
4897
+ }
4439
4898
  function foreign2(operation) {
4440
4899
  return Effect5.tryPromise({ try: operation, catch: (cause) => cause });
4441
4900
  }
@@ -4535,12 +4994,6 @@ function isPermissionDenied(cause) {
4535
4994
  function isAccessInvalid2(cause) {
4536
4995
  return typeof cause === "object" && cause !== null && "code" in cause && (cause.code === "access.invalid" || cause.code === "invalid_access");
4537
4996
  }
4538
- function isSyncRejection(cause) {
4539
- return isAccessInvalid2(cause) || cause instanceof V1SyncError && cause.code === "terminal_failure";
4540
- }
4541
- function canApplyForcedMediaState(snapshot) {
4542
- return !!snapshot.media && !!snapshot.participantId && snapshot.control?.status !== "ended" && snapshot.connection.phase !== "terminal";
4543
- }
4544
4997
 
4545
4998
  // src/space-client/offline-actions.ts
4546
4999
  import { Context as Context7, Effect as Effect6, Layer as Layer7 } from "effect";
@@ -5470,8 +5923,8 @@ var SpaceClientCore = class {
5470
5923
  subscribe = (listener) => this.#store.subscribe(listener);
5471
5924
  join(options = {}) {
5472
5925
  return Effect12.sync(() => this.#requireActive()).pipe(
5473
- Effect12.tap(() => Effect12.sync(() => this.controllers.media.configure(options))),
5474
5926
  Effect12.andThen(this.#connection.configureJoin(options)),
5927
+ Effect12.tap(() => Effect12.sync(() => this.controllers.media.configure(options))),
5475
5928
  Effect12.andThen(this.#connection.join()),
5476
5929
  Effect12.andThen(options.displayName === void 0 ? Effect12.void : this.controllers.participants.renameSelf(options.displayName)),
5477
5930
  Effect12.catchTag("ConnectionLifecycleFailure", (failure2) => Effect12.fail(failure2.code === "episode_ended" ? new SpaceClientError({ code: "episode.ended", recoverable: failure2.recoverable, message: failure2.message }) : failure2)),
@@ -5671,6 +6124,7 @@ export {
5671
6124
  InMemoryChalkWhiteboardV1PendingOperationStore,
5672
6125
  ChalkWhiteboardV1Client,
5673
6126
  createChalkWhiteboardV1Client,
6127
+ ChalkWhiteboardViewBounds,
5674
6128
  makeProductionConnectionPlatformLayer,
5675
6129
  ConnectionLifecycleService,
5676
6130
  makeConnectionLifecycleLayer,