@ai-matrx/meet 0.11.47 → 0.11.49

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/react.cjs CHANGED
@@ -487,10 +487,11 @@ function webTabPlatform() {
487
487
 
488
488
  // src/core/connection-supervisor.ts
489
489
  var DEFAULT_CONNECTION_POLICY = {
490
- bannerDelayMs: 2e3,
490
+ bannerDelayMs: 5e3,
491
491
  autoRejoin: true,
492
492
  autoRejoinMaxAttempts: 5,
493
- reconnectGraceSeconds: 30
493
+ reconnectGraceSeconds: 30,
494
+ videoAutoOffAfterPoorSeconds: 20
494
495
  };
495
496
  function clampInt(value, min, max, fallback) {
496
497
  if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
@@ -504,7 +505,8 @@ function connectionPolicyFrom(raw) {
504
505
  bannerDelayMs: clampInt(row["reconnect_banner_delay_ms"], 0, 1e4, d.bannerDelayMs),
505
506
  autoRejoin: typeof row["auto_rejoin"] === "boolean" ? row["auto_rejoin"] : d.autoRejoin,
506
507
  autoRejoinMaxAttempts: clampInt(row["auto_rejoin_max_attempts"], 0, 20, d.autoRejoinMaxAttempts),
507
- reconnectGraceSeconds: clampInt(row["reconnect_grace_seconds"], 0, 600, d.reconnectGraceSeconds)
508
+ reconnectGraceSeconds: clampInt(row["reconnect_grace_seconds"], 0, 600, d.reconnectGraceSeconds),
509
+ videoAutoOffAfterPoorSeconds: clampInt(row["video_auto_off_after_poor_seconds"], 0, 600, d.videoAutoOffAfterPoorSeconds)
508
510
  };
509
511
  }
510
512
  var LIVEKIT_BASE_DELAYS_MS = [0, 300, 1200, 2700, 4800, 7e3];
@@ -2771,11 +2773,14 @@ function recordingLabel(state) {
2771
2773
 
2772
2774
  // src/core/live-policy.ts
2773
2775
  var DEFAULT_LIVE_POLICY = {
2776
+ headlessPowers: "members_admit",
2774
2777
  chatMode: "everyone",
2775
2778
  avLock: "off",
2776
2779
  timeLimitMinutes: 0,
2777
2780
  timeLimitWarningMinutes: 5,
2778
- recordingConsent: "notice"
2781
+ recordingConsent: "notice",
2782
+ shareWhoCan: "everyone",
2783
+ shareConcurrency: "one_takeover"
2779
2784
  };
2780
2785
  function oneOf(value, allowed, fallback) {
2781
2786
  return typeof value === "string" && allowed.includes(value) ? value : fallback;
@@ -2787,6 +2792,7 @@ function whole(value, fallback) {
2787
2792
  function livePolicyFrom(raw) {
2788
2793
  const o = typeof raw === "object" && raw !== null ? raw : {};
2789
2794
  return {
2795
+ headlessPowers: oneOf(o["headless_powers"], ["members_admit", "none", "cohosts_only"], DEFAULT_LIVE_POLICY.headlessPowers),
2790
2796
  chatMode: oneOf(o["chat_mode"], ["everyone", "hosts_only", "off"], DEFAULT_LIVE_POLICY.chatMode),
2791
2797
  avLock: oneOf(o["attendee_av_lock"], ["off", "mic", "camera", "both"], DEFAULT_LIVE_POLICY.avLock),
2792
2798
  timeLimitMinutes: whole(o["time_limit_minutes"], 0),
@@ -2795,17 +2801,33 @@ function livePolicyFrom(raw) {
2795
2801
  o["recording_consent"],
2796
2802
  ["notice", "ok_or_leave", "view_only_until_agree"],
2797
2803
  DEFAULT_LIVE_POLICY.recordingConsent
2798
- )
2804
+ ),
2805
+ shareWhoCan: oneOf(o["share_who_can"], ["everyone", "host_only", "org", "presenters"], DEFAULT_LIVE_POLICY.shareWhoCan),
2806
+ shareConcurrency: oneOf(o["share_concurrency"], ["one_takeover", "one_replace", "multiple"], DEFAULT_LIVE_POLICY.shareConcurrency)
2799
2807
  };
2800
2808
  }
2801
2809
  function samePolicy(a, b) {
2802
- return a.chatMode === b.chatMode && a.avLock === b.avLock && a.timeLimitMinutes === b.timeLimitMinutes && a.timeLimitWarningMinutes === b.timeLimitWarningMinutes && a.recordingConsent === b.recordingConsent;
2810
+ return a.headlessPowers === b.headlessPowers && a.chatMode === b.chatMode && a.avLock === b.avLock && a.timeLimitMinutes === b.timeLimitMinutes && a.timeLimitWarningMinutes === b.timeLimitWarningMinutes && a.recordingConsent === b.recordingConsent && a.shareWhoCan === b.shareWhoCan && a.shareConcurrency === b.shareConcurrency;
2803
2811
  }
2804
2812
  var MODERATORS = /* @__PURE__ */ new Set(["host", "cohost"]);
2805
2813
  function isLockedFor(policy, role, kind) {
2806
2814
  if (MODERATORS.has(role)) return false;
2807
2815
  return policy.avLock === "both" || (kind === "microphone" ? policy.avLock === "mic" : policy.avLock === "camera");
2808
2816
  }
2817
+ function canShareUnder(policy, role, standing) {
2818
+ if (role === "viewer") return false;
2819
+ if (MODERATORS.has(role)) return true;
2820
+ switch (policy.shareWhoCan) {
2821
+ case "host_only":
2822
+ return false;
2823
+ case "presenters":
2824
+ return role === "presenter";
2825
+ case "org":
2826
+ return role === "presenter" || standing === "member";
2827
+ default:
2828
+ return true;
2829
+ }
2830
+ }
2809
2831
  function consentPending(policy, role, recordingOn, given) {
2810
2832
  return recordingOn && !given && policy.recordingConsent !== "notice" && !MODERATORS.has(role);
2811
2833
  }
@@ -2817,6 +2839,12 @@ function minutesLeft(policy, startedAtMs, nowMs) {
2817
2839
  if (policy.timeLimitMinutes <= 0 || startedAtMs === null) return null;
2818
2840
  return Math.ceil((startedAtMs + policy.timeLimitMinutes * 6e4 - nowMs) / 6e4);
2819
2841
  }
2842
+ function mayAdmitNow(role, participants, policy) {
2843
+ if (role === "none") return false;
2844
+ if (role === "host" || role === "cohost") return true;
2845
+ const hostIn = participants.some((p) => p.role === "host" || p.role === "cohost");
2846
+ return !hostIn && policy.headlessPowers === "members_admit";
2847
+ }
2820
2848
 
2821
2849
  // src/core/attendance.ts
2822
2850
  var IDLE_PREVIEW = { state: "idle", unavailable: null };
@@ -4277,6 +4305,7 @@ var NO_GRANTS = {
4277
4305
  canPublish: false,
4278
4306
  canSubscribe: false
4279
4307
  };
4308
+ var UNKNOWN_LOCAL_NETWORK = { path: "unknown", quality: "unknown", videoPaused: false };
4280
4309
  var NO_SHARE = { state: "none", reason: null, remedy: null, at: 0 };
4281
4310
  var EMPTY = {
4282
4311
  phase: "resolving",
@@ -4285,6 +4314,7 @@ var EMPTY = {
4285
4314
  roomName: null,
4286
4315
  connection: "stable",
4287
4316
  connectionBanner: false,
4317
+ localNetwork: UNKNOWN_LOCAL_NETWORK,
4288
4318
  share: NO_SHARE,
4289
4319
  call: null,
4290
4320
  waiting: null,
@@ -5866,6 +5896,14 @@ function applyModeration(store, payload, now) {
5866
5896
  }
5867
5897
  return HANDLED;
5868
5898
  }
5899
+ case "share-ended": {
5900
+ const reason = text2(payload["reason"]) === "not_allowed" ? "not_allowed" : "replaced";
5901
+ const by = text2(payload["by"]);
5902
+ store.patch({
5903
+ share: { state: "ended", reason, remedy: null, at: now(), ...by !== null ? { by } : {} }
5904
+ });
5905
+ return HANDLED;
5906
+ }
5869
5907
  case "muted":
5870
5908
  case "ask-unmute":
5871
5909
  case "removed": {
@@ -5963,6 +6001,187 @@ var TRANSCRIPTION_ATTRIBUTES = {
5963
6001
  speakerIdentity: "lk.transcribed_participant_identity"
5964
6002
  };
5965
6003
 
6004
+ // src/core/network-health.ts
6005
+ function pathOf(reading) {
6006
+ if (reading.relayed === null && reading.tcp === null) return "unknown";
6007
+ return reading.relayed === true || reading.tcp === true ? "restricted" : "direct";
6008
+ }
6009
+ var POOR_RTT_MS = 300;
6010
+ var RECOVERED_RTT_MS = 220;
6011
+ var POOR_LOSS = 0.08;
6012
+ var RECOVERED_LOSS = 0.03;
6013
+ function createSelfAssessor(options = {}) {
6014
+ const poorSamples = options.poorSamples ?? 2;
6015
+ const goodSamples = options.goodSamples ?? 3;
6016
+ let quality = "unknown";
6017
+ let badRun = 0;
6018
+ let goodRun = 0;
6019
+ return {
6020
+ push(reading) {
6021
+ const { rttMs, lossRatio } = reading;
6022
+ if (rttMs === null && lossRatio === null) return quality;
6023
+ const bad = rttMs !== null && rttMs >= POOR_RTT_MS || lossRatio !== null && lossRatio >= POOR_LOSS;
6024
+ const clear = (rttMs === null || rttMs < RECOVERED_RTT_MS) && (lossRatio === null || lossRatio < RECOVERED_LOSS);
6025
+ badRun = bad ? badRun + 1 : 0;
6026
+ goodRun = clear ? goodRun + 1 : 0;
6027
+ if (badRun >= poorSamples) quality = "poor";
6028
+ else if (quality !== "poor" && !bad) quality = "good";
6029
+ else if (quality === "poor" && goodRun >= goodSamples) quality = "good";
6030
+ return quality;
6031
+ },
6032
+ current: () => quality,
6033
+ reset() {
6034
+ quality = "unknown";
6035
+ badRun = 0;
6036
+ goodRun = 0;
6037
+ }
6038
+ };
6039
+ }
6040
+ var RANK = {
6041
+ lost: 0,
6042
+ poor: 1,
6043
+ good: 2,
6044
+ excellent: 3,
6045
+ unknown: 4
6046
+ };
6047
+ function worstQuality(a, b) {
6048
+ if (b === null || b === void 0 || b === "unknown") return a;
6049
+ if (a === "unknown") return b;
6050
+ return RANK[b] < RANK[a] ? b : a;
6051
+ }
6052
+ var ALIVE_TOPIC = "mx.alive";
6053
+ function encodeBeat(beat) {
6054
+ return JSON.stringify({ q: beat.q, vp: beat.vp });
6055
+ }
6056
+ function decodeBeat(payload) {
6057
+ try {
6058
+ const raw = JSON.parse(payload);
6059
+ const q = raw.q === "good" || raw.q === "poor" ? raw.q : "unknown";
6060
+ return { q, vp: raw.vp === true };
6061
+ } catch {
6062
+ return null;
6063
+ }
6064
+ }
6065
+ function heartbeatIntervalMs(roomSize) {
6066
+ return 2e3 * Math.max(1, Math.ceil(Math.max(1, roomSize) / 25));
6067
+ }
6068
+ function staleAfterMs(roomSize) {
6069
+ return heartbeatIntervalMs(roomSize) * 3 + 1e3;
6070
+ }
6071
+ function createLivenessTracker(now) {
6072
+ const lastHeard = /* @__PURE__ */ new Map();
6073
+ const beats = /* @__PURE__ */ new Map();
6074
+ return {
6075
+ heard(who, beat) {
6076
+ lastHeard.set(who, now());
6077
+ if (beat !== null) beats.set(who, beat);
6078
+ },
6079
+ stale(present, roomSize) {
6080
+ const limit = staleAfterMs(roomSize);
6081
+ const at = now();
6082
+ const out = /* @__PURE__ */ new Set();
6083
+ for (const who of present) {
6084
+ const heard = lastHeard.get(who);
6085
+ if (heard !== void 0 && at - heard > limit) out.add(who);
6086
+ }
6087
+ return out;
6088
+ },
6089
+ reported: (who) => beats.get(who) ?? null,
6090
+ restartClock() {
6091
+ const at = now();
6092
+ for (const who of lastHeard.keys()) lastHeard.set(who, at);
6093
+ },
6094
+ forget(who) {
6095
+ lastHeard.delete(who);
6096
+ beats.delete(who);
6097
+ },
6098
+ clear() {
6099
+ lastHeard.clear();
6100
+ beats.clear();
6101
+ }
6102
+ };
6103
+ }
6104
+ function createVideoAutoOff(afterMs, now) {
6105
+ let poorSince = null;
6106
+ let armed = true;
6107
+ return {
6108
+ step(quality, cameraOn) {
6109
+ if (quality === "good") {
6110
+ poorSince = null;
6111
+ armed = true;
6112
+ return null;
6113
+ }
6114
+ if (quality !== "poor" || !cameraOn) {
6115
+ poorSince = quality === "poor" ? poorSince : null;
6116
+ return null;
6117
+ }
6118
+ const at = now();
6119
+ if (poorSince === null) poorSince = at;
6120
+ const limit = afterMs();
6121
+ if (!armed || limit <= 0 || at - poorSince < limit) return null;
6122
+ armed = false;
6123
+ return "pause";
6124
+ },
6125
+ retried() {
6126
+ armed = false;
6127
+ },
6128
+ reset() {
6129
+ poorSince = null;
6130
+ armed = true;
6131
+ }
6132
+ };
6133
+ }
6134
+ function readingFromStats(entries) {
6135
+ const byId2 = /* @__PURE__ */ new Map();
6136
+ for (const entry of entries) byId2.set(entry.id, entry);
6137
+ let pair;
6138
+ for (const entry of byId2.values()) {
6139
+ if (entry.type === "transport" && typeof entry["selectedCandidatePairId"] === "string") {
6140
+ pair = byId2.get(entry["selectedCandidatePairId"]);
6141
+ if (pair !== void 0) break;
6142
+ }
6143
+ }
6144
+ if (pair === void 0) {
6145
+ for (const entry of byId2.values()) {
6146
+ if (entry.type === "candidate-pair" && entry["nominated"] === true && entry["state"] === "succeeded") {
6147
+ pair = entry;
6148
+ break;
6149
+ }
6150
+ }
6151
+ }
6152
+ let lossRatio = null;
6153
+ for (const entry of byId2.values()) {
6154
+ if (entry.type === "remote-inbound-rtp" && typeof entry["fractionLost"] === "number") {
6155
+ lossRatio = Math.max(lossRatio ?? 0, entry["fractionLost"]);
6156
+ }
6157
+ }
6158
+ if (pair === void 0) return { rttMs: null, lossRatio, relayed: null, tcp: null };
6159
+ const rtt = pair["currentRoundTripTime"];
6160
+ const local = typeof pair["localCandidateId"] === "string" ? byId2.get(pair["localCandidateId"]) : void 0;
6161
+ const candidateType = local?.["candidateType"];
6162
+ const protocol = local?.["protocol"];
6163
+ const relayProtocol = local?.["relayProtocol"];
6164
+ return {
6165
+ rttMs: typeof rtt === "number" ? Math.round(rtt * 1e3) : null,
6166
+ lossRatio,
6167
+ relayed: typeof candidateType === "string" ? candidateType === "relay" : null,
6168
+ tcp: typeof protocol === "string" || typeof relayProtocol === "string" ? protocol === "tcp" || relayProtocol === "tcp" || relayProtocol === "tls" : null
6169
+ };
6170
+ }
6171
+ function mergeReadings(readings) {
6172
+ const max = (values) => {
6173
+ const known = values.filter((v) => v !== null);
6174
+ return known.length === 0 ? null : Math.max(...known);
6175
+ };
6176
+ const any = (values) => values.every((v) => v === null) ? null : values.some((v) => v === true);
6177
+ return {
6178
+ rttMs: max(readings.map((r) => r.rttMs)),
6179
+ lossRatio: max(readings.map((r) => r.lossRatio)),
6180
+ relayed: any(readings.map((r) => r.relayed)),
6181
+ tcp: any(readings.map((r) => r.tcp))
6182
+ };
6183
+ }
6184
+
5966
6185
  // src/core/room.ts
5967
6186
  function SCREEN_SHARE_BLOCKED_REMEDY_FOR(remedy) {
5968
6187
  return /administrator|policy/i.test(remedy) ? remedy : SCREEN_SHARE_BLOCKED_REMEDY;
@@ -6055,6 +6274,76 @@ function createRoomEngine(options) {
6055
6274
  return personConnections.get(personOf(who)) ?? [who];
6056
6275
  }
6057
6276
  const departures = /* @__PURE__ */ new Map();
6277
+ const liveness = createLivenessTracker(now);
6278
+ const assessor = createSelfAssessor();
6279
+ const autoOff = createVideoAutoOff(() => connectionPolicy.videoAutoOffAfterPoorSeconds * 1e3, now);
6280
+ const lkQuality = /* @__PURE__ */ new Map();
6281
+ let silent = /* @__PURE__ */ new Set();
6282
+ let healthTimer = null;
6283
+ let lastBeatAt = 0;
6284
+ let wasUnstable = false;
6285
+ function patchLocalNetwork(next) {
6286
+ const before = store.snapshot().localNetwork;
6287
+ const merged = { ...before, ...next };
6288
+ if (merged.path !== before.path || merged.quality !== before.quality || merged.videoPaused !== before.videoPaused) {
6289
+ store.patch({ localNetwork: merged });
6290
+ }
6291
+ }
6292
+ function effectiveQuality(person, connection, isLocal) {
6293
+ const fromServer = lkQuality.get(person) ?? "unknown";
6294
+ if (isLocal) return worstQuality(fromServer, assessor.current());
6295
+ return worstQuality(fromServer, connection === void 0 ? null : liveness.reported(connection)?.q);
6296
+ }
6297
+ function refreshQualities() {
6298
+ const snap = store.snapshot();
6299
+ for (const p of snap.participants) {
6300
+ if (p.reconnecting === true) continue;
6301
+ const next = effectiveQuality(p.identity, mainConnection.get(p.identity), p.isLocal);
6302
+ if (next !== "unknown" && next !== p.quality) store.setQuality(p.identity, next);
6303
+ }
6304
+ }
6305
+ function stopHealth() {
6306
+ if (healthTimer !== null) clearInterval(healthTimer);
6307
+ healthTimer = null;
6308
+ liveness.clear();
6309
+ assessor.reset();
6310
+ autoOff.reset();
6311
+ silent = /* @__PURE__ */ new Set();
6312
+ lkQuality.clear();
6313
+ lastBeatAt = 0;
6314
+ wasUnstable = false;
6315
+ if (store.snapshot().localNetwork !== UNKNOWN_LOCAL_NETWORK) store.patch({ localNetwork: UNKNOWN_LOCAL_NETWORK });
6316
+ }
6317
+ function healthTick() {
6318
+ const snap = store.snapshot();
6319
+ const target = driver;
6320
+ if (disposed || target === null || snap.phase !== "in_call") return;
6321
+ if (snap.connection !== "stable") {
6322
+ wasUnstable = true;
6323
+ return;
6324
+ }
6325
+ if (wasUnstable) {
6326
+ wasUnstable = false;
6327
+ liveness.restartClock();
6328
+ }
6329
+ const size = liveRoster.length;
6330
+ const at = now();
6331
+ if (at - lastBeatAt >= heartbeatIntervalMs(size)) {
6332
+ lastBeatAt = at;
6333
+ const beat = encodeBeat({ q: assessor.current(), vp: snap.localNetwork.videoPaused });
6334
+ void target.publishData(ALIVE_TOPIC, beat).catch(() => void 0);
6335
+ }
6336
+ const remotes = liveRoster.filter((p) => !p.isLocal).map((p) => p.identity);
6337
+ const next = liveness.stale(remotes, size);
6338
+ if (next.size !== silent.size || [...next].some((who) => !silent.has(who))) {
6339
+ silent = next;
6340
+ projectRoster(liveRoster, new Date(at).toISOString());
6341
+ }
6342
+ }
6343
+ function startHealth() {
6344
+ if (healthTimer !== null) return;
6345
+ healthTimer = setInterval(healthTick, 1e3);
6346
+ }
6058
6347
  function requestAsNow() {
6059
6348
  if (lastRequest === null) return null;
6060
6349
  const media2 = store.snapshot().media;
@@ -6073,6 +6362,7 @@ function createRoomEngine(options) {
6073
6362
  media.onDisconnected();
6074
6363
  store.patch({ share: NO_SHARE, connection: "stable" });
6075
6364
  clearGhosts();
6365
+ stopHealth();
6076
6366
  await target?.disconnect();
6077
6367
  }
6078
6368
  async function leaveRoom() {
@@ -6164,6 +6454,8 @@ function createRoomEngine(options) {
6164
6454
  },
6165
6455
  participantLeft(who, reason) {
6166
6456
  departures.set(who, reason);
6457
+ liveness.forget(who);
6458
+ silent.delete(who);
6167
6459
  },
6168
6460
  roomMetadata(metadata) {
6169
6461
  let parsed = {};
@@ -6197,7 +6489,27 @@ function createRoomEngine(options) {
6197
6489
  store.setSpeaking([...new Set(identities.map((who) => personOf(who)))]);
6198
6490
  },
6199
6491
  quality(who, value) {
6200
- store.setQuality(personOf(who), value);
6492
+ const person = personOf(who);
6493
+ lkQuality.set(person, value);
6494
+ const local = store.snapshot().participants.find((p) => p.identity === person)?.isLocal === true;
6495
+ store.setQuality(person, value === "lost" ? value : effectiveQuality(person, who, local));
6496
+ },
6497
+ networkReading(reading) {
6498
+ const snap = store.snapshot();
6499
+ if (snap.phase !== "in_call" || snap.connection !== "stable") return;
6500
+ startHealth();
6501
+ const quality = assessor.push(reading);
6502
+ const path = pathOf(reading);
6503
+ patchLocalNetwork({ quality, ...path !== "unknown" ? { path } : {} });
6504
+ refreshQualities();
6505
+ const cameraOn = snap.media.camera.enabled;
6506
+ if (autoOff.step(quality, cameraOn) === "pause" && driver !== null) {
6507
+ const target = driver;
6508
+ report("info", "meet: the camera was paused because the connection is poor", "The audio is kept; Try video again turns it back on.");
6509
+ void enableTrack(target, "camera", false).then((state) => {
6510
+ if (!state.enabled) patchLocalNetwork({ videoPaused: true });
6511
+ });
6512
+ }
6201
6513
  },
6202
6514
  connectionState(state) {
6203
6515
  supervisor.driverState(state);
@@ -6292,6 +6604,22 @@ function createRoomEngine(options) {
6292
6604
  return;
6293
6605
  }
6294
6606
  const sender = personOf(from ?? "");
6607
+ if (from !== null) {
6608
+ const beat = topic === ALIVE_TOPIC ? decodeBeat(payload) : null;
6609
+ const before = liveness.reported(from);
6610
+ liveness.heard(from, beat);
6611
+ if (topic === ALIVE_TOPIC) {
6612
+ startHealth();
6613
+ if (beat !== null && (before?.q !== beat.q || before?.vp !== beat.vp || silent.has(from))) {
6614
+ silent.delete(from);
6615
+ projectRoster(liveRoster, new Date(now()).toISOString());
6616
+ refreshQualities();
6617
+ } else if (silent.delete(from)) {
6618
+ projectRoster(liveRoster, new Date(now()).toISOString());
6619
+ }
6620
+ return;
6621
+ }
6622
+ }
6295
6623
  switch (topic) {
6296
6624
  case DATA_TOPICS.reaction:
6297
6625
  if (typeof envelope.emoji === "string") {
@@ -6534,7 +6862,16 @@ function createRoomEngine(options) {
6534
6862
  store.setParticipants(
6535
6863
  [...people.values()].map(({ entry }) => {
6536
6864
  const projected = projectParticipant(entry, joinedAt);
6537
- return entry.reconnecting === true ? { ...projected, reconnecting: true, quality: "lost" } : projected;
6865
+ if (entry.reconnecting === true || !entry.isLocal && silent.has(entry.identity)) {
6866
+ return { ...projected, reconnecting: true, quality: "lost" };
6867
+ }
6868
+ const videoPaused = entry.isLocal ? store.snapshot().localNetwork.videoPaused : liveness.reported(entry.identity)?.vp === true;
6869
+ const quality = effectiveQuality(projected.identity, entry.identity, entry.isLocal);
6870
+ return {
6871
+ ...projected,
6872
+ ...quality !== "unknown" && quality !== "lost" ? { quality } : {},
6873
+ ...videoPaused ? { videoPaused: true } : {}
6874
+ };
6538
6875
  })
6539
6876
  );
6540
6877
  scheduleGhostExpiry();
@@ -6720,7 +7057,12 @@ function createRoomEngine(options) {
6720
7057
  },
6721
7058
  async setCameraEnabled(enabled) {
6722
7059
  if (driver === null) return;
6723
- await enableTrack(driver, "camera", enabled);
7060
+ const state = await enableTrack(driver, "camera", enabled);
7061
+ if (enabled && state.enabled && store.snapshot().localNetwork.videoPaused) {
7062
+ autoOff.retried();
7063
+ patchLocalNetwork({ videoPaused: false });
7064
+ }
7065
+ if (!enabled && store.snapshot().localNetwork.videoPaused) patchLocalNetwork({ videoPaused: false });
6724
7066
  },
6725
7067
  async setScreenShareEnabled(enabled) {
6726
7068
  if (driver === null) return;
@@ -6766,7 +7108,8 @@ function createRoomEngine(options) {
6766
7108
  }
6767
7109
  },
6768
7110
  dismissShareNotice() {
6769
- if (store.snapshot().share.state === "refused") store.patch({ share: NO_SHARE });
7111
+ const state = store.snapshot().share.state;
7112
+ if (state === "refused" || state === "ended") store.patch({ share: NO_SHARE });
6770
7113
  },
6771
7114
  async react(emoji) {
6772
7115
  const local = store.snapshot().localIdentity;
@@ -7244,6 +7587,30 @@ function createLiveKitDriver(handlers, options = {}) {
7244
7587
  let projectRoster = null;
7245
7588
  let audioCapture = DEFAULT_AUDIO_PROCESSING;
7246
7589
  let videoEffect = { kind: "none" };
7590
+ let statsTimer = null;
7591
+ function stopStatsPoll() {
7592
+ if (statsTimer !== null) clearInterval(statsTimer);
7593
+ statsTimer = null;
7594
+ }
7595
+ function startStatsPoll(target) {
7596
+ stopStatsPoll();
7597
+ if (handlers.networkReading === void 0) return;
7598
+ const sample = async () => {
7599
+ if (room !== target) return;
7600
+ const manager = target.engine.pcManager;
7601
+ if (manager === void 0) return;
7602
+ const readings = [];
7603
+ for (const transport of [manager.publisher, manager.subscriber]) {
7604
+ try {
7605
+ const report = await transport?.getStats();
7606
+ if (report !== void 0) readings.push(readingFromStats(report.values()));
7607
+ } catch {
7608
+ }
7609
+ }
7610
+ if (readings.length > 0 && room === target) handlers.networkReading?.(mergeReadings(readings));
7611
+ };
7612
+ statsTimer = setInterval(() => void sample(), 2e3);
7613
+ }
7247
7614
  async function load() {
7248
7615
  if (room !== null) return room;
7249
7616
  let module2;
@@ -7318,6 +7685,7 @@ function createLiveKitDriver(handlers, options = {}) {
7318
7685
  participant?.identity ?? null
7319
7686
  );
7320
7687
  }).on(RoomEvent.Disconnected, (reason) => {
7688
+ stopStatsPoll();
7321
7689
  audioContainer?.remove();
7322
7690
  audioContainer = null;
7323
7691
  handlers.disconnected(reasonName(reason));
@@ -7403,6 +7771,7 @@ function createLiveKitDriver(handlers, options = {}) {
7403
7771
  projectRoster?.();
7404
7772
  handlers.roomMetadata?.(target.metadata ?? "");
7405
7773
  handlers.audioPlayback?.(target.canPlaybackAudio);
7774
+ startStatsPoll(target);
7406
7775
  },
7407
7776
  async disconnect() {
7408
7777
  if (room === null) return;
@@ -7413,6 +7782,7 @@ function createLiveKitDriver(handlers, options = {}) {
7413
7782
  } catch {
7414
7783
  }
7415
7784
  projectRoster = null;
7785
+ stopStatsPoll();
7416
7786
  audioContainer?.remove();
7417
7787
  audioContainer = null;
7418
7788
  await target.disconnect();
@@ -8007,7 +8377,7 @@ function observe(input) {
8007
8377
  const phase = observedPhase(input);
8008
8378
  const inCall = phase === "in_call";
8009
8379
  const actual = inCall && snapshot !== null ? effectiveRole(snapshot) : "none";
8010
- const canAdmit = actual !== "none" && ADMITTER_ROLES.has(actual);
8380
+ const canAdmit = snapshot !== null && mayAdmitNow(actual, snapshot.participants, snapshot.livePolicy);
8011
8381
  const role = actual === "none" || snapshot === null ? "none" : observedRole(actual, effectiveStanding(snapshot));
8012
8382
  const deviceInPlay = phase === "joining" || inCall;
8013
8383
  const region = snapshot?.mediaRegion;
@@ -8863,6 +9233,13 @@ function requireSession(session, action) {
8863
9233
  if (session === null) throw noRuntime(action);
8864
9234
  return session;
8865
9235
  }
9236
+ function otherPresenterOf(snapshot) {
9237
+ if (snapshot === null || snapshot.presenter === null || snapshot.presenter === snapshot.localIdentity) return null;
9238
+ const rule = snapshot.livePolicy.shareConcurrency;
9239
+ if (rule === "multiple") return null;
9240
+ const who = snapshot.participants.find((p) => p.identity === snapshot.presenter);
9241
+ return who === void 0 ? null : { name: who.displayName, rule };
9242
+ }
8866
9243
  function useRoom() {
8867
9244
  const host = useMeetHost();
8868
9245
  const snapshot = useMeetSnapshot();
@@ -8877,7 +9254,9 @@ function useRoom() {
8877
9254
  secondDevice: snapshot?.secondDevice ?? null,
8878
9255
  local,
8879
9256
  // An observer (role `viewer`) publishes nothing: no Share screen to press and be refused (MD-16).
8880
- canShareScreen: (engine?.supportsScreenShare() ?? true) && !isObserverRole(snapshot?.grants.role ?? null),
9257
+ sharingAllowed: snapshot === null || canShareUnder(snapshot.livePolicy, effectiveRole(snapshot), effectiveStanding(snapshot)),
9258
+ canShareScreen: (engine?.supportsScreenShare() ?? true) && !isObserverRole(snapshot?.grants.role ?? null) && (snapshot === null || canShareUnder(snapshot.livePolicy, effectiveRole(snapshot), effectiveStanding(snapshot))),
9259
+ otherPresenter: otherPresenterOf(snapshot),
8881
9260
  join: (0, import_react4.useCallback)(
8882
9261
  async (request) => requireEngine(engine, "join").join(request),
8883
9262
  [engine]
@@ -9028,7 +9407,7 @@ function useLobby() {
9028
9407
  declined: (snapshot?.lobby ?? []).filter((entry) => entry.state === "denied"),
9029
9408
  // No runtime means no host powers — `<LobbyPanel>` renders nothing rather
9030
9409
  // than an admit button that would throw.
9031
- canAdmit: session !== null && isHost,
9410
+ canAdmit: session !== null && (isHost || snapshot !== null && snapshot.phase === "in_call" && mayAdmitNow(snapshot.participants.find((p) => p.identity === snapshot.localIdentity)?.role ?? "none", snapshot.participants, snapshot.livePolicy)),
9032
9411
  admit: (0, import_react4.useCallback)(
9033
9412
  async (entry) => requireSession(session, "admit").admit(entry, true),
9034
9413
  [session]
@@ -11902,7 +12281,8 @@ function ParticipantTile({
11902
12281
  participant.isLocal ? " (you)" : "",
11903
12282
  participant.isAgent ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "mx-meet__badge", children: "AI" }) : null,
11904
12283
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(RoleBadge, { role: participant.role, standing: participant.standing }),
11905
- participant.reconnecting === true ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "mx-meet__badge", children: "Reconnecting\u2026" }) : null
12284
+ participant.reconnecting === true ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "mx-meet__badge", children: "Reconnecting\u2026" }) : null,
12285
+ participant.reconnecting !== true && participant.videoPaused === true && !participant.isLocal ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "mx-meet__badge", "data-meet-video-paused": "", children: "Video paused to save audio" }) : null
11906
12286
  ] }),
11907
12287
  !participant.micEnabled ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(MicOffIcon, { width: 14, height: 14 }) : null,
11908
12288
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(QualityBars, { quality: participant.quality })
@@ -11992,6 +12372,8 @@ function ControlBar({ onLeave }) {
11992
12372
  const queue = useHandQueue();
11993
12373
  const micUnavailable = useLocalMedia().microphone.unavailable;
11994
12374
  const live = useLivePolicy();
12375
+ const core = useMeetCore();
12376
+ const [askPresentInstead, setAskPresentInstead] = (0, import_react10.useState)(false);
11995
12377
  const unread = useUnreadChat(panel === "chat");
11996
12378
  const [whiteboard, setWhiteboard] = (0, import_react10.useState)(false);
11997
12379
  const app = useMeetAppPanel();
@@ -12080,15 +12462,39 @@ function ControlBar({ onLeave }) {
12080
12462
  }
12081
12463
  ),
12082
12464
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(CaptionsControl, {}),
12083
- room.canShareScreen || room.local?.screenSharing === true ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
12084
- ControlButton,
12085
- {
12086
- label: room.local?.screenSharing === true ? "Stop sharing" : "Share screen",
12087
- active: room.local?.screenSharing === true,
12088
- onClick: () => void room.toggleScreenShare(),
12089
- children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(ScreenShareIcon, {})
12090
- }
12091
- ) : null,
12465
+ room.canShareScreen || room.local?.screenSharing === true && core.share.state !== "ended" ? /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { className: "mx-meet__anchor", children: [
12466
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
12467
+ ControlButton,
12468
+ {
12469
+ label: room.local?.screenSharing === true ? "Stop sharing" : "Share screen",
12470
+ active: room.local?.screenSharing === true,
12471
+ onClick: () => {
12472
+ if (room.local?.screenSharing !== true && room.otherPresenter !== null) setAskPresentInstead(true);
12473
+ else void room.toggleScreenShare();
12474
+ },
12475
+ children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(ScreenShareIcon, {})
12476
+ }
12477
+ ),
12478
+ askPresentInstead && room.otherPresenter !== null ? /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "mx-meet__confirm", role: "group", "aria-label": "Someone else is presenting", "data-meet-notice": "share-takeover", children: [
12479
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { className: "mx-meet__confirm-text", children: [
12480
+ room.otherPresenter.name,
12481
+ " is presenting"
12482
+ ] }),
12483
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
12484
+ "button",
12485
+ {
12486
+ type: "button",
12487
+ className: "mx-meet__primary mx-meet__primary--small",
12488
+ onClick: () => {
12489
+ setAskPresentInstead(false);
12490
+ void room.toggleScreenShare();
12491
+ },
12492
+ children: room.otherPresenter.rule === "one_takeover" ? "Present instead" : "Share anyway"
12493
+ }
12494
+ ),
12495
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("button", { type: "button", className: "mx-meet__link", onClick: () => setAskPresentInstead(false), children: "Cancel" })
12496
+ ] }) : null
12497
+ ] }) : null,
12092
12498
  /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { className: "mx-meet__anchor", children: [
12093
12499
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
12094
12500
  ControlButton,
@@ -12185,7 +12591,7 @@ function ControlBar({ onLeave }) {
12185
12591
  ]
12186
12592
  }
12187
12593
  ),
12188
- !room.canShareScreen && room.local?.screenSharing !== true ? (
12594
+ !room.canShareScreen && room.sharingAllowed && room.local?.screenSharing !== true ? (
12189
12595
  // `share-unsupported-mobile`: the control is absent; the menu says where it lives.
12190
12596
  /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("p", { className: "mx-meet__menu-note", role: "note", "data-meet-notice": "share-unsupported", children: [
12191
12597
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(ScreenShareIcon, { width: 16, height: 16 }),
@@ -14190,13 +14596,53 @@ function MeetSkinRoom({
14190
14596
  ] }),
14191
14597
  core.connectionBanner && core.connection !== "stable" ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("p", { className: "mx-meet__banner", role: "status", "data-meet-notice": `connection-${core.connection}`, children: CONNECTION_BANNER[core.connection] }) : null,
14192
14598
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(ShareNotice, {}),
14599
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(NetworkNotice, {}),
14193
14600
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(Stage, {}),
14194
14601
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(Captions, {}),
14195
14602
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(ControlBar, { ...onLeave !== void 0 ? { onLeave } : {} })
14196
14603
  ] });
14197
14604
  }
14605
+ var RESTRICTED_NOTICE_MS = 3e4;
14606
+ function NetworkNotice() {
14607
+ const { snapshot, connectionBanner } = useMeetCore();
14608
+ const room = useRoom();
14609
+ const network = snapshot?.localNetwork;
14610
+ const [closed, setClosed] = (0, import_react10.useState)(() => /* @__PURE__ */ new Set());
14611
+ const [restrictedSince, setRestrictedSince] = (0, import_react10.useState)(null);
14612
+ const [, setTick] = (0, import_react10.useState)(0);
14613
+ const restricted = network?.path === "restricted";
14614
+ (0, import_react10.useEffect)(() => {
14615
+ if (!restricted || restrictedSince !== null) return;
14616
+ setRestrictedSince(Date.now());
14617
+ const timer = setTimeout(() => setTick((n) => n + 1), RESTRICTED_NOTICE_MS + 50);
14618
+ return () => clearTimeout(timer);
14619
+ }, [restricted, restrictedSince]);
14620
+ if (network === void 0 || snapshot?.phase !== "in_call" || connectionBanner) return null;
14621
+ const close = (code) => () => setClosed((prev) => /* @__PURE__ */ new Set([...prev, code]));
14622
+ const cameraOn = room.local?.cameraEnabled === true;
14623
+ if (network.videoPaused && !cameraOn) {
14624
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("p", { className: "mx-meet__banner", role: "status", "data-meet-notice": "video-paused", children: [
14625
+ "Your video is paused to save your audio",
14626
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("button", { type: "button", className: "mx-meet__link", onClick: () => void room.toggleCamera(), children: "Try video again" })
14627
+ ] });
14628
+ }
14629
+ if (network.quality === "poor" && !closed.has("connection-poor")) {
14630
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("p", { className: "mx-meet__banner", role: "status", "data-meet-notice": "connection-poor", children: [
14631
+ "Your connection is unstable",
14632
+ cameraOn ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("button", { type: "button", className: "mx-meet__link", onClick: () => void room.toggleCamera(), children: "Turn off camera" }) : null,
14633
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("button", { type: "button", className: "mx-meet__link", onClick: close("connection-poor"), children: "Dismiss" })
14634
+ ] });
14635
+ }
14636
+ if (restricted && !closed.has("restricted-network") && restrictedSince !== null && Date.now() - restrictedSince < RESTRICTED_NOTICE_MS) {
14637
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("p", { className: "mx-meet__banner", role: "status", "data-meet-notice": "restricted-network", children: [
14638
+ "Restricted network \xB7 quality may be reduced",
14639
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("button", { type: "button", className: "mx-meet__link", onClick: close("restricted-network"), children: "Dismiss" })
14640
+ ] });
14641
+ }
14642
+ return null;
14643
+ }
14198
14644
  function ShareNotice() {
14199
- const { share, actions } = useMeetCore();
14645
+ const { share, actions, snapshot } = useMeetCore();
14200
14646
  const [soundHintClosed, setSoundHintClosed] = (0, import_react10.useState)(null);
14201
14647
  if (share.state === "presenting" && share.audio === false && soundHintClosed !== share.at) {
14202
14648
  return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("p", { className: "mx-meet__banner", role: "status", "data-meet-notice": "share-audio-missing", children: [
@@ -14205,6 +14651,16 @@ function ShareNotice() {
14205
14651
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("button", { type: "button", className: "mx-meet__link", onClick: () => setSoundHintClosed(share.at), children: "Dismiss" })
14206
14652
  ] });
14207
14653
  }
14654
+ if (share.state === "ended") {
14655
+ const replaced = share.reason === "replaced";
14656
+ const rule = snapshot?.livePolicy.shareWhoCan ?? "host_only";
14657
+ const why = replaced ? `${share.by ?? "Someone"} is now presenting` : rule === "presenters" ? "Sharing is turned off. Only presenters can share their screen" : rule === "org" ? "Sharing is turned off. Only people in the organization can share their screen" : "Sharing is turned off. Only the host can share their screen";
14658
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("p", { className: "mx-meet__banner", role: "status", "data-meet-notice": replaced ? "share-ended" : "share-unavailable", children: [
14659
+ replaced ? "Your presentation ended" : "Your presentation was stopped",
14660
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "mx-meet__remedy", children: why }),
14661
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("button", { type: "button", className: "mx-meet__link", onClick: () => actions.dismissShareNotice(), children: "Dismiss" })
14662
+ ] });
14663
+ }
14208
14664
  if (share.state !== "refused") return null;
14209
14665
  const text3 = share.reason === "unsupported" ? "You can't share your screen from this browser" : share.reason === "blocked" ? "Screen sharing is blocked" : "Couldn't start sharing your screen";
14210
14666
  return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("p", { className: "mx-meet__banner", role: "status", "data-meet-notice": "share-unavailable", children: [
@@ -14623,6 +15079,7 @@ function MeetingRecordBody({
14623
15079
  }
14624
15080
  if (record.loading || record.record === null) {
14625
15081
  return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { className: "mx-meet mx-meet--record", children: /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "mx-meet__empty", role: "status", children: [
15082
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { className: "mx-meet__empty-title", "data-meet-notice": "meeting-ended", children: "The meeting has ended" }),
14626
15083
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { className: "mx-meet__empty-title", children: "Opening the meeting record\u2026" }),
14627
15084
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { className: "mx-meet__empty-body", children: "Reading the summary, the notes and the transcript this meeting left behind." })
14628
15085
  ] }) });