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