@ai-matrx/meet 0.11.43 → 0.11.44

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
@@ -158,6 +158,7 @@ __export(react_exports, {
158
158
  createReconnectPolicy: () => createReconnectPolicy,
159
159
  createRecordingController: () => createRecordingController,
160
160
  createRoomEngine: () => createRoomEngine,
161
+ createSpeakingWhileMutedDetector: () => createSpeakingWhileMutedDetector,
161
162
  createTokenClient: () => createTokenClient,
162
163
  createWebDeviceStorage: () => createWebDeviceStorage,
163
164
  createWebEffectsStorage: () => createWebEffectsStorage,
@@ -166,6 +167,7 @@ __export(react_exports, {
166
167
  createWebRoomTokenStorage: () => createWebRoomTokenStorage,
167
168
  defaultCaptionLanguage: () => defaultCaptionLanguage,
168
169
  detectBrowser: () => detectBrowser,
170
+ detectCapabilities: () => detectCapabilities,
169
171
  detectOs: () => detectOs,
170
172
  deviceChipLabel: () => deviceChipLabel,
171
173
  deviceFixSteps: () => deviceFixSteps,
@@ -190,6 +192,7 @@ __export(react_exports, {
190
192
  isLinkFollower: () => isLinkFollower,
191
193
  isListenOnly: () => isListenOnly,
192
194
  isLivePhase: () => isLivePhase,
195
+ isMobileUserAgent: () => isMobileUserAgent,
193
196
  isNoteTaker: () => isNoteTaker,
194
197
  isObserverRole: () => isObserverRole,
195
198
  isRealName: () => isRealName,
@@ -282,6 +285,7 @@ __export(react_exports, {
282
285
  transcriptDocument: () => transcriptDocument,
283
286
  unavailableFrom: () => unavailableFrom,
284
287
  uniqueAttendees: () => uniqueAttendees,
288
+ unsupportedWords: () => unsupportedWords,
285
289
  untranslatedLines: () => untranslatedLines,
286
290
  useActivityAttention: () => useActivityAttention,
287
291
  useAudioLevel: () => useAudioLevel,
@@ -2792,11 +2796,13 @@ function minutesLeft(policy, startedAtMs, nowMs) {
2792
2796
  }
2793
2797
 
2794
2798
  // src/core/attendance.ts
2799
+ var IDLE_PREVIEW = { state: "idle", unavailable: null };
2795
2800
  var IDLE_MEDIA_REGION = {
2796
2801
  permission: { microphone: "unknown", camera: "unknown" },
2797
2802
  audioOutput: "ok",
2798
2803
  notice: null,
2799
- offer: null
2804
+ offer: null,
2805
+ preview: { microphone: IDLE_PREVIEW, camera: IDLE_PREVIEW }
2800
2806
  };
2801
2807
  var IDLE_TRACK = { enabled: false, unavailable: null };
2802
2808
  var IDLE_MEDIA = {
@@ -5151,6 +5157,47 @@ function createMeetSessionIdentity(options) {
5151
5157
  });
5152
5158
  }
5153
5159
 
5160
+ // src/core/capabilities.ts
5161
+ var IN_APP = [
5162
+ [/Instagram/i, "Instagram"],
5163
+ [/FBAN|FBAV|FB_IAB/i, "Facebook"],
5164
+ [/LinkedInApp/i, "LinkedIn"],
5165
+ [/MicroMessenger/i, "WeChat"],
5166
+ [/Snapchat/i, "Snapchat"],
5167
+ [/\bLine\//i, "LINE"],
5168
+ [/Twitter|TwitterAndroid/i, "X"],
5169
+ [/TikTok|musical_ly|BytedanceWebview/i, "TikTok"]
5170
+ ];
5171
+ function isMobileUserAgent(userAgent, maxTouchPoints = 0) {
5172
+ if (/iPhone|iPod|Android|Mobile|iPad/i.test(userAgent)) return true;
5173
+ return /Macintosh/.test(userAgent) && maxTouchPoints > 1;
5174
+ }
5175
+ function detectCapabilities(scope = typeof globalThis === "undefined" ? null : globalThis) {
5176
+ if (scope === null || scope.navigator === void 0) {
5177
+ return { canJoin: true, reason: null, inAppBrowser: null, mobile: false, canShareScreen: false };
5178
+ }
5179
+ const ua = scope.navigator.userAgent ?? "";
5180
+ const webrtc = typeof scope.RTCPeerConnection === "function" || typeof scope.webkitRTCPeerConnection === "function";
5181
+ const mobile = isMobileUserAgent(ua, scope.navigator.maxTouchPoints ?? 0);
5182
+ const inAppBrowser = IN_APP.find(([pattern]) => pattern.test(ua))?.[1] ?? null;
5183
+ return {
5184
+ canJoin: webrtc,
5185
+ reason: webrtc ? null : "no_webrtc",
5186
+ inAppBrowser,
5187
+ mobile,
5188
+ canShareScreen: !mobile && typeof scope.navigator.mediaDevices?.getDisplayMedia === "function"
5189
+ };
5190
+ }
5191
+ function unsupportedWords(capabilities) {
5192
+ if (capabilities.inAppBrowser !== null) {
5193
+ return {
5194
+ title: `${capabilities.inAppBrowser}'s browser can't join calls`,
5195
+ body: "Open this link in Chrome, Safari, Edge or Firefox"
5196
+ };
5197
+ }
5198
+ return { title: "This browser can't join calls", body: "Open this link in Chrome, Safari, Edge or Firefox" };
5199
+ }
5200
+
5154
5201
  // src/core/media-supervisor.ts
5155
5202
  function classifyDeviceFailure(error, permission) {
5156
5203
  if (permission === "denied") return "denied";
@@ -5211,7 +5258,7 @@ function deviceFixSteps(kind, reason, where) {
5211
5258
  case "safari":
5212
5259
  return where.os === "ios" ? [`Settings \u203A Apps \u203A Safari \u203A ${device}`, "Choose Allow", "Return here and press Try again"] : ["Safari \u203A Settings for This Website", `Set ${device} to Allow`, "Press Try again"];
5213
5260
  default:
5214
- return ["Click the icon left of the web address", `Set ${device} to Allow`, "Press Try again"];
5261
+ return ["Click the site settings icon in the address bar", `Set ${device} to Allow`, "Press Try again"];
5215
5262
  }
5216
5263
  case "blocked_os": {
5217
5264
  const app = where.browser === "chrome" ? "Google Chrome" : where.browser === "edge" ? "Microsoft Edge" : where.browser === "firefox" ? "Firefox" : "your browser";
@@ -5309,7 +5356,7 @@ function createMediaSupervisor(options) {
5309
5356
  function setRegion(next) {
5310
5357
  const current = region();
5311
5358
  const merged = { ...current, ...next };
5312
- if (merged.audioOutput === current.audioOutput && merged.notice === current.notice && merged.offer === current.offer && merged.permission.microphone === current.permission.microphone && merged.permission.camera === current.permission.camera) {
5359
+ if (merged.audioOutput === current.audioOutput && merged.notice === current.notice && merged.offer === current.offer && merged.preview === current.preview && merged.permission.microphone === current.permission.microphone && merged.permission.camera === current.permission.camera) {
5313
5360
  return;
5314
5361
  }
5315
5362
  store.patch({ mediaRegion: merged });
@@ -5405,7 +5452,7 @@ function createMediaSupervisor(options) {
5405
5452
  if (permission !== "unknown") setPermission(kind, permission);
5406
5453
  return unavailableFrom(normalized, classifyDeviceFailure(normalized, permission));
5407
5454
  }
5408
- return {
5455
+ const api = {
5409
5456
  start() {
5410
5457
  if (started || platform === null) return;
5411
5458
  started = true;
@@ -5427,6 +5474,16 @@ function createMediaSupervisor(options) {
5427
5474
  started = false;
5428
5475
  },
5429
5476
  unavailable,
5477
+ async preview(kind, outcome) {
5478
+ api.start();
5479
+ let next;
5480
+ if (typeof outcome === "string") next = { state: outcome, unavailable: null };
5481
+ else next = { state: "unavailable", unavailable: await unavailable(outcome.error, kind, `meet.${kind}Preview`) };
5482
+ const current = region().preview;
5483
+ const was = current[kind];
5484
+ if (was.state === next.state && was.unavailable?.reason === next.unavailable?.reason && was.unavailable?.message === next.unavailable?.message) return;
5485
+ setRegion({ preview: { ...region().preview, [kind]: next } });
5486
+ },
5430
5487
  onAudioPlayback(canPlay) {
5431
5488
  setRegion({ audioOutput: canPlay ? "ok" : "blocked" });
5432
5489
  },
@@ -5485,6 +5542,7 @@ function createMediaSupervisor(options) {
5485
5542
  setRegion({ notice: null });
5486
5543
  }
5487
5544
  };
5545
+ return api;
5488
5546
  }
5489
5547
 
5490
5548
  // src/core/room-events.ts
@@ -5996,7 +6054,8 @@ function createRoomEngine(options) {
5996
6054
  message: normalized.message,
5997
6055
  remedy: normalized.remedy,
5998
6056
  reason: normalized.reason,
5999
- retryAt: normalized.retryAfterSeconds === null ? null : now() + normalized.retryAfterSeconds * 1e3
6057
+ retryAt: normalized.retryAfterSeconds === null ? null : now() + normalized.retryAfterSeconds * 1e3,
6058
+ stage: "join"
6000
6059
  }
6001
6060
  });
6002
6061
  store.dispatch({ type: "join_failed", reason: normalized.reason ?? null });
@@ -6387,6 +6446,10 @@ function createRoomEngine(options) {
6387
6446
  }
6388
6447
  departures.clear();
6389
6448
  liveRoster = list;
6449
+ const localNow = list.find((p) => p.isLocal);
6450
+ if (localNow !== void 0 && !localNow.screenSharing && store.snapshot().share.state === "presenting") {
6451
+ store.patch({ share: NO_SHARE });
6452
+ }
6390
6453
  const merged = mergeGhosts(list, ghosts, at);
6391
6454
  ghosts = merged.kept;
6392
6455
  const people = /* @__PURE__ */ new Map();
@@ -6606,7 +6669,10 @@ function createRoomEngine(options) {
6606
6669
  try {
6607
6670
  await driver.setScreenShareEnabled(enabled);
6608
6671
  ambiguousShareDenials = 0;
6609
- store.patch({ share: { state: enabled ? "presenting" : "none", reason: null, remedy: null, at: now() } });
6672
+ const audio = enabled ? driver?.screenShareHasAudio?.() : void 0;
6673
+ store.patch({
6674
+ share: { state: enabled ? "presenting" : "none", reason: null, remedy: null, at: now(), ...audio !== void 0 ? { audio } : {} }
6675
+ });
6610
6676
  } catch (error) {
6611
6677
  const kind = classifyScreenShareError(error);
6612
6678
  if (kind === "ambiguous") {
@@ -6673,13 +6739,14 @@ function createRoomEngine(options) {
6673
6739
  return driver?.supportsVideoEffects?.() ?? false;
6674
6740
  },
6675
6741
  supportsScreenShare() {
6676
- return driver?.supportsScreenShare?.() ?? true;
6742
+ return driver?.supportsScreenShare?.() ?? detectCapabilities().canShareScreen;
6677
6743
  },
6678
6744
  async switchDevice(kind, deviceId2) {
6679
6745
  if (driver === null) return;
6680
6746
  await driver.switchDevice(kind, deviceId2);
6681
6747
  },
6682
6748
  enableAudio: () => media.enableAudio(),
6749
+ previewDevice: (kind, outcome) => media.preview(kind, outcome),
6683
6750
  acceptDeviceOffer: () => media.acceptOffer(),
6684
6751
  dismissDeviceOffer: () => media.dismissOffer(),
6685
6752
  dismissDeviceNotice: () => media.dismissNotice(),
@@ -7151,7 +7218,15 @@ function createLiveKitDriver(handlers, options = {}) {
7151
7218
  created.on(RoomEvent.ParticipantConnected, roster).on(RoomEvent.ParticipantDisconnected, (participant, reason) => {
7152
7219
  handlers.participantLeft?.(participant.identity, reasonName(reason));
7153
7220
  roster();
7154
- }).on(RoomEvent.TrackSubscribed, roster).on(RoomEvent.TrackUnsubscribed, roster).on(RoomEvent.TrackMuted, roster).on(RoomEvent.TrackUnmuted, roster).on(RoomEvent.LocalTrackPublished, roster).on(RoomEvent.LocalTrackUnpublished, roster).on(RoomEvent.ParticipantNameChanged, roster).on(RoomEvent.ParticipantAttributesChanged, roster).on(RoomEvent.ParticipantMetadataChanged, roster).on(RoomEvent.RoomMetadataChanged, (metadata) => {
7221
+ }).on(RoomEvent.TrackSubscribed, roster).on(RoomEvent.TrackUnsubscribed, roster).on(RoomEvent.TrackMuted, roster).on(RoomEvent.TrackUnmuted, roster).on(RoomEvent.LocalTrackPublished, (publication) => {
7222
+ if (publication.source === Track.Source.ScreenShare) {
7223
+ const ended = () => {
7224
+ void created.localParticipant.setScreenShareEnabled(false).catch(() => void 0);
7225
+ };
7226
+ publication.track?.mediaStreamTrack.addEventListener("ended", ended, { once: true });
7227
+ }
7228
+ roster();
7229
+ }).on(RoomEvent.LocalTrackUnpublished, roster).on(RoomEvent.ParticipantNameChanged, roster).on(RoomEvent.ParticipantAttributesChanged, roster).on(RoomEvent.ParticipantMetadataChanged, roster).on(RoomEvent.RoomMetadataChanged, (metadata) => {
7155
7230
  handlers.roomMetadata?.(metadata ?? "");
7156
7231
  }).on(RoomEvent.AudioPlaybackStatusChanged, (canPlay) => {
7157
7232
  handlers.audioPlayback?.(canPlay);
@@ -7292,7 +7367,10 @@ function createLiveKitDriver(handlers, options = {}) {
7292
7367
  },
7293
7368
  async setScreenShareEnabled(enabled) {
7294
7369
  try {
7295
- await requireRoom("setScreenShareEnabled").localParticipant.setScreenShareEnabled(enabled);
7370
+ await requireRoom("setScreenShareEnabled").localParticipant.setScreenShareEnabled(
7371
+ enabled,
7372
+ enabled ? { audio: true, systemAudio: "include" } : void 0
7373
+ );
7296
7374
  } catch (error) {
7297
7375
  throw normalizeScreenShareError(error, "meet.setScreenShareEnabled");
7298
7376
  }
@@ -7331,8 +7409,13 @@ function createLiveKitDriver(handlers, options = {}) {
7331
7409
  supportsVideoEffects() {
7332
7410
  return videoEffectsSupported();
7333
7411
  },
7412
+ screenShareHasAudio() {
7413
+ const target = room;
7414
+ if (target === null) return false;
7415
+ return target.localParticipant.getTrackPublications().some((p) => String(p.source) === "screen_share_audio" && p.track !== void 0);
7416
+ },
7334
7417
  supportsScreenShare() {
7335
- return typeof navigator !== "undefined" && typeof navigator.mediaDevices?.getDisplayMedia === "function";
7418
+ return detectCapabilities().canShareScreen;
7336
7419
  },
7337
7420
  attach(identity, kind, element) {
7338
7421
  const target = requireRoom("attach");
@@ -7818,6 +7901,21 @@ function observedDevice(track, permission = "unknown", held = null) {
7818
7901
  const reason = deviceUnavailableReason(track, permission);
7819
7902
  return reason === null ? "muted_by_me" : `unavailable:${reason}`;
7820
7903
  }
7904
+ function observedPreview(preview, permission = "unknown") {
7905
+ if (permission === "denied") return "unavailable:denied";
7906
+ switch (preview.state) {
7907
+ case "on":
7908
+ return "on";
7909
+ case "off":
7910
+ return "muted_by_me";
7911
+ case "prompting":
7912
+ return "prompting";
7913
+ case "unavailable":
7914
+ return `unavailable:${preview.unavailable?.reason ?? "failed"}`;
7915
+ default:
7916
+ return null;
7917
+ }
7918
+ }
7821
7919
  function endedRecord(snapshot, given) {
7822
7920
  const record = snapshot?.meeting ?? given;
7823
7921
  return record !== null && record.endedAt !== null;
@@ -7841,18 +7939,22 @@ function observe(input) {
7841
7939
  const canAdmit = actual !== "none" && ADMITTER_ROLES.has(actual);
7842
7940
  const role = actual === "none" || snapshot === null ? "none" : observedRole(actual, effectiveStanding(snapshot));
7843
7941
  const deviceInPlay = phase === "joining" || inCall;
7942
+ const region = snapshot?.mediaRegion;
7943
+ const atPrejoin = phase === "prejoin" && region !== void 0;
7844
7944
  return {
7845
7945
  phase,
7846
7946
  reason: phase === "ended" ? snapshot?.reason ?? null : snapshot?.reason ?? reasonOf(phase),
7847
7947
  role,
7848
- connection: snapshot?.connection ?? "stable",
7948
+ // `net-blip`: in the call, a drop shorter than `reconnect_banner_delay_ms` is not something the
7949
+ // person sees — the contract says what is on screen, so it reads `stable` until the banner shows.
7950
+ connection: snapshot === null ? "stable" : inCall && snapshot.connection !== "stable" && !snapshot.connectionBanner ? "stable" : snapshot.connection,
7849
7951
  hostPresent: inCall && snapshot !== null ? snapshot.participants.some((p) => ADMITTER_ROLES.has(p.role)) : snapshot !== null && isWaitingPhase(snapshot.phase) ? snapshot.waiting?.admittersPresent ?? null : null,
7850
7952
  lobbyCount: canAdmit && snapshot !== null ? snapshot.lobby.filter((entry) => entry.state === "knocking").length : 0,
7851
7953
  recording: snapshot?.recording === "recording" || snapshot?.recording === "stopping",
7852
7954
  // S6c: the autoplay region is known only inside the room (no remote audio exists elsewhere).
7853
7955
  audioBlocked: inCall && snapshot !== null ? snapshot.mediaRegion.audioOutput === "blocked" : null,
7854
- camera: deviceInPlay && snapshot !== null ? observedDevice(snapshot.media.camera, snapshot.mediaRegion.permission.camera, lockedFor(snapshot, "camera", inCall)) : null,
7855
- microphone: deviceInPlay && snapshot !== null ? observedDevice(snapshot.media.microphone, snapshot.mediaRegion.permission.microphone, lockedFor(snapshot, "microphone", inCall)) : null
7956
+ camera: deviceInPlay && snapshot !== null ? observedDevice(snapshot.media.camera, snapshot.mediaRegion.permission.camera, lockedFor(snapshot, "camera", inCall)) : atPrejoin ? observedPreview(region.preview.camera, region.permission.camera) : null,
7957
+ microphone: deviceInPlay && snapshot !== null ? observedDevice(snapshot.media.microphone, snapshot.mediaRegion.permission.microphone, lockedFor(snapshot, "microphone", inCall)) : atPrejoin ? observedPreview(region.preview.microphone, region.permission.microphone) : null
7856
7958
  };
7857
7959
  }
7858
7960
  function lockedFor(snapshot, kind, inCall) {
@@ -7952,7 +8054,13 @@ function MeetRoot({
7952
8054
  ...options
7953
8055
  }) {
7954
8056
  const snapshot = useMeetSnapshot();
7955
- const observation = useCoreObservation(options);
8057
+ const probed = useCapabilities();
8058
+ const base = useCoreObservation(options);
8059
+ const unsupported = probed !== null && !probed.canJoin;
8060
+ const observation = (0, import_react3.useMemo)(
8061
+ () => unsupported ? { ...staticObservation("unsupported", probed?.reason ?? null) } : base,
8062
+ [unsupported, probed, base]
8063
+ );
7956
8064
  const attrs = (0, import_react3.useMemo)(() => rootAttributes(observation), [observation]);
7957
8065
  const latest = (0, import_react3.useRef)({ observation, storePhase: snapshot?.phase ?? null });
7958
8066
  latest.current = { observation, storePhase: snapshot?.phase ?? null };
@@ -7970,7 +8078,34 @@ function MeetRoot({
7970
8078
  if (target.__matrxMeet === diagnostics) delete target.__matrxMeet;
7971
8079
  };
7972
8080
  }, []);
7973
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { ...attrs, style: { display: "contents" }, children });
8081
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { ...attrs, style: { display: "contents" }, children: unsupported && probed !== null ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(UnsupportedBrowser, { capabilities: probed }) : children });
8082
+ }
8083
+ function useCapabilities() {
8084
+ const [found, setFound] = (0, import_react3.useState)(null);
8085
+ (0, import_react3.useEffect)(() => {
8086
+ setFound(detectCapabilities());
8087
+ }, []);
8088
+ return found;
8089
+ }
8090
+ function UnsupportedBrowser({ capabilities }) {
8091
+ const words = unsupportedWords(capabilities);
8092
+ const [copied, setCopied] = (0, import_react3.useState)(false);
8093
+ const href = typeof window === "undefined" ? "" : window.location.href;
8094
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "mx-meet", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mx-meet__waiting", "data-meet-exit": "unsupported", children: [
8095
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h1", { className: "mx-meet__prejoin-title", children: words.title }),
8096
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "mx-meet__hint", role: "status", "data-meet-notice": "browser-unsupported", children: words.body }),
8097
+ href.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
8098
+ "button",
8099
+ {
8100
+ type: "button",
8101
+ className: "mx-meet__primary",
8102
+ onClick: () => {
8103
+ void navigator.clipboard?.writeText(href).then(() => setCopied(true), () => void 0);
8104
+ },
8105
+ children: copied ? "Link copied" : "Copy link"
8106
+ }
8107
+ ) : null
8108
+ ] }) });
7974
8109
  }
7975
8110
 
7976
8111
  // src/react/hooks.ts
@@ -8670,7 +8805,8 @@ function useRoom() {
8670
8805
  failure: snapshot?.failure ?? null,
8671
8806
  secondDevice: snapshot?.secondDevice ?? null,
8672
8807
  local,
8673
- canShareScreen: engine?.supportsScreenShare() ?? true,
8808
+ // An observer (role `viewer`) publishes nothing: no Share screen to press and be refused (MD-16).
8809
+ canShareScreen: (engine?.supportsScreenShare() ?? true) && !isObserverRole(snapshot?.grants.role ?? null),
8674
8810
  join: (0, import_react4.useCallback)(
8675
8811
  async (request) => requireEngine(engine, "join").join(request),
8676
8812
  [engine]
@@ -9149,6 +9285,12 @@ function useMediaSupervisor() {
9149
9285
  retry: (0, import_react4.useCallback)(
9150
9286
  async (kind) => kind === "microphone" ? engine?.setMicrophoneEnabled(true) : engine?.setCameraEnabled(true),
9151
9287
  [engine]
9288
+ ),
9289
+ reportPreview: (0, import_react4.useCallback)(
9290
+ (kind, outcome) => {
9291
+ void engine?.previewDevice(kind, outcome);
9292
+ },
9293
+ [engine]
9152
9294
  )
9153
9295
  };
9154
9296
  }
@@ -10244,6 +10386,39 @@ function useCollabPanelRequest(onRequest) {
10244
10386
  // src/core/reactions.ts
10245
10387
  var MEET_REACTIONS = ["\u{1F496}", "\u{1F44D}", "\u{1F389}", "\u{1F44F}", "\u{1F602}", "\u{1F62E}", "\u{1F622}", "\u{1F914}", "\u{1F44E}"];
10246
10388
 
10389
+ // src/core/speaking-detector.ts
10390
+ function createSpeakingWhileMutedDetector(options = {}) {
10391
+ const threshold = options.threshold ?? 0.06;
10392
+ const holdMs = options.holdMs ?? 600;
10393
+ const gapMs = options.gapMs ?? 400;
10394
+ const cooldownMs = options.cooldownMs ?? 2e4;
10395
+ let speechStart = null;
10396
+ let lastLoud = null;
10397
+ let firedAt = null;
10398
+ return {
10399
+ feed(level, at) {
10400
+ if (level >= threshold) {
10401
+ if (speechStart === null || lastLoud === null || at - lastLoud > gapMs) speechStart = at;
10402
+ lastLoud = at;
10403
+ } else if (lastLoud !== null && at - lastLoud > gapMs) {
10404
+ speechStart = null;
10405
+ lastLoud = null;
10406
+ }
10407
+ if (speechStart === null || at - speechStart < holdMs) return false;
10408
+ if (firedAt !== null && at - firedAt < cooldownMs) return false;
10409
+ firedAt = at;
10410
+ speechStart = null;
10411
+ lastLoud = null;
10412
+ return true;
10413
+ },
10414
+ reset() {
10415
+ speechStart = null;
10416
+ lastLoud = null;
10417
+ firedAt = null;
10418
+ }
10419
+ };
10420
+ }
10421
+
10247
10422
  // src/react/facade.ts
10248
10423
  var NO_ACTIONS = {
10249
10424
  join: async () => void 0,
@@ -10547,6 +10722,9 @@ function UndoIcon(props) {
10547
10722
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("path", { d: "M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11" })
10548
10723
  ] });
10549
10724
  }
10725
+ function ChevronUpIcon(props) {
10726
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Icon, { ...props, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("path", { d: "m6 15 6-6 6 6" }) });
10727
+ }
10550
10728
 
10551
10729
  // src/skins/meet/collab.tsx
10552
10730
  var import_jsx_runtime5 = require("react/jsx-runtime");
@@ -11800,6 +11978,7 @@ function ControlBar({ onLeave }) {
11800
11978
  ) }) }) : null,
11801
11979
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(BreakoutBanner, {}),
11802
11980
  panel !== "people" ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(KnockNotice, { onView: () => setPanel("people") }) : null,
11981
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(SpeakingWhileMutedNudge, {}),
11803
11982
  controls.notice !== null && panel !== "people" ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(Toast, { text: controls.notice.text, failed: controls.notice.failed, onDone: controls.clearNotice }) : null,
11804
11983
  /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "mx-meet__controls", children: [
11805
11984
  live.micLocked ? null : /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
@@ -11811,6 +11990,7 @@ function ControlBar({ onLeave }) {
11811
11990
  children: room.local?.micEnabled === true ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(MicIcon, {}) : /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(MicOffIcon, {})
11812
11991
  }
11813
11992
  ),
11993
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(AudioSettingsControl, {}),
11814
11994
  live.cameraLocked ? null : /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
11815
11995
  ControlButton,
11816
11996
  {
@@ -11926,6 +12106,13 @@ function ControlBar({ onLeave }) {
11926
12106
  ]
11927
12107
  }
11928
12108
  ),
12109
+ !room.canShareScreen && room.local?.screenSharing !== true ? (
12110
+ // `share-unsupported-mobile`: the control is absent; the menu says where it lives.
12111
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("p", { className: "mx-meet__menu-note", role: "note", "data-meet-notice": "share-unsupported", children: [
12112
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(ScreenShareIcon, { width: 16, height: 16 }),
12113
+ " Present from a computer"
12114
+ ] })
12115
+ ) : null,
11929
12116
  controls.isHost ? /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(import_jsx_runtime6.Fragment, { children: [
11930
12117
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "mx-meet__menu-sep", role: "separator" }),
11931
12118
  /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
@@ -12200,6 +12387,94 @@ function Toast({ text: text3, failed, onDone }) {
12200
12387
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("button", { type: "button", className: "mx-meet__toast-close", "aria-label": "Dismiss", onClick: onDone, children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(CloseIcon, { width: 14, height: 14 }) })
12201
12388
  ] });
12202
12389
  }
12390
+ function AudioSettingsControl() {
12391
+ const host = useMeetHost();
12392
+ const devices = useDeviceSnapshot();
12393
+ const [open, setOpen] = (0, import_react10.useState)(false);
12394
+ const list = (devices?.devices.audioinput ?? []).filter((device) => device.deviceId.length > 0);
12395
+ if (host === null || list.length < 2) return null;
12396
+ const current = devices?.selected.audioinput ?? host.devices.resolve("audioinput");
12397
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { className: "mx-meet__anchor", children: [
12398
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
12399
+ ControlButton,
12400
+ {
12401
+ label: "Audio settings",
12402
+ active: open,
12403
+ onClick: () => {
12404
+ if (!open) void host.devices.refresh();
12405
+ setOpen(!open);
12406
+ },
12407
+ children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(ChevronUpIcon, {})
12408
+ }
12409
+ ),
12410
+ open ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(Popover, { label: "Microphone", onClose: () => setOpen(false), children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "mx-meet__menu", role: "menu", "aria-label": "Microphone", children: list.map((device) => /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
12411
+ "button",
12412
+ {
12413
+ type: "button",
12414
+ role: "menuitemradio",
12415
+ "aria-checked": device.deviceId === current,
12416
+ className: "mx-meet__menu-item",
12417
+ onClick: () => {
12418
+ setOpen(false);
12419
+ host.devices.select("audioinput", device.deviceId);
12420
+ void host.engine.switchDevice("audioinput", device.deviceId).catch(() => void 0);
12421
+ },
12422
+ children: [
12423
+ device.deviceId === current ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(CheckIcon, { width: 16, height: 16 }) : /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "mx-meet__menu-gap" }),
12424
+ device.label.length > 0 ? device.label : "Microphone"
12425
+ ]
12426
+ },
12427
+ device.deviceId
12428
+ )) }) }) : null
12429
+ ] });
12430
+ }
12431
+ function SpeakingWhileMutedNudge() {
12432
+ const room = useRoom();
12433
+ const host = useMeetHost();
12434
+ const devices = useDeviceSnapshot();
12435
+ const microphone = useLocalMedia().microphone;
12436
+ const mutedByMe = host !== null && room.local != null && room.local.micEnabled !== true && microphone.unavailable === null;
12437
+ const mic = devices?.selected.audioinput ?? host?.devices.resolve("audioinput") ?? null;
12438
+ const stream = useMicrophoneStream(mutedByMe ? mic : "off");
12439
+ const level = useAudioLevel(stream);
12440
+ const latest = (0, import_react10.useRef)(0);
12441
+ latest.current = level;
12442
+ const [shown, setShown] = (0, import_react10.useState)(false);
12443
+ const [detector] = (0, import_react10.useState)(() => createSpeakingWhileMutedDetector());
12444
+ (0, import_react10.useEffect)(() => {
12445
+ if (!mutedByMe) {
12446
+ detector.reset();
12447
+ setShown(false);
12448
+ return void 0;
12449
+ }
12450
+ const timer = setInterval(() => {
12451
+ if (detector.feed(latest.current, Date.now())) setShown(true);
12452
+ }, 150);
12453
+ return () => clearInterval(timer);
12454
+ }, [mutedByMe, detector]);
12455
+ (0, import_react10.useEffect)(() => {
12456
+ if (!shown) return void 0;
12457
+ const timer = setTimeout(() => setShown(false), 8e3);
12458
+ return () => clearTimeout(timer);
12459
+ }, [shown]);
12460
+ if (!shown || !mutedByMe) return null;
12461
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "mx-meet__notice-bar", role: "status", "data-meet-notice": "speaking-while-muted", children: [
12462
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(MicOffIcon, { width: 16, height: 16 }),
12463
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { children: "You're muted" }),
12464
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
12465
+ "button",
12466
+ {
12467
+ type: "button",
12468
+ className: "mx-meet__primary mx-meet__primary--small",
12469
+ onClick: () => {
12470
+ setShown(false);
12471
+ void room.toggleMicrophone();
12472
+ },
12473
+ children: "Unmute"
12474
+ }
12475
+ )
12476
+ ] });
12477
+ }
12203
12478
  function MediaNotices() {
12204
12479
  const media = useLocalMedia();
12205
12480
  const { region, where, enableAudio, acceptOffer, dismissOffer, dismissNotice, retry } = useMediaSupervisor();
@@ -13049,14 +13324,20 @@ async function playTestSound(sinkId) {
13049
13324
  audio.pause();
13050
13325
  await context.close();
13051
13326
  }
13052
- function useMicrophoneStream(deviceId2) {
13327
+ function useMicrophoneStream(deviceId2, report, attempt = 0) {
13053
13328
  const [stream, setStream] = (0, import_react10.useState)(null);
13329
+ const reportRef = (0, import_react10.useRef)(report);
13330
+ reportRef.current = report;
13054
13331
  (0, import_react10.useEffect)(() => {
13055
- if (deviceId2 === "off") return void 0;
13332
+ if (deviceId2 === "off") {
13333
+ reportRef.current?.("off");
13334
+ return void 0;
13335
+ }
13056
13336
  let live = true;
13057
13337
  let opened = null;
13058
13338
  const media = globalThis.navigator?.mediaDevices;
13059
13339
  if (media === void 0) return void 0;
13340
+ reportRef.current?.("prompting");
13060
13341
  void media.getUserMedia({ audio: deviceId2 === null ? true : { deviceId: { exact: deviceId2 } } }).then((next) => {
13061
13342
  if (!live) {
13062
13343
  next.getTracks().forEach((track) => track.stop());
@@ -13064,15 +13345,49 @@ function useMicrophoneStream(deviceId2) {
13064
13345
  }
13065
13346
  opened = next;
13066
13347
  setStream(next);
13067
- }).catch(() => setStream(null));
13348
+ reportRef.current?.("on");
13349
+ }).catch((thrown) => {
13350
+ setStream(null);
13351
+ if (live) reportRef.current?.({ error: thrown });
13352
+ });
13068
13353
  return () => {
13069
13354
  live = false;
13070
13355
  opened?.getTracks().forEach((track) => track.stop());
13071
13356
  setStream(null);
13072
13357
  };
13073
- }, [deviceId2]);
13358
+ }, [deviceId2, attempt]);
13074
13359
  return stream;
13075
13360
  }
13361
+ function PreviewStatus({ onRetry }) {
13362
+ const { region, where } = useMediaSupervisor();
13363
+ const [waited, setWaited] = (0, import_react10.useState)(false);
13364
+ const prompting = region.preview.microphone.state === "prompting" || region.preview.camera.state === "prompting";
13365
+ (0, import_react10.useEffect)(() => {
13366
+ if (!prompting) {
13367
+ setWaited(false);
13368
+ return void 0;
13369
+ }
13370
+ const timer = setTimeout(() => setWaited(true), 800);
13371
+ return () => clearTimeout(timer);
13372
+ }, [prompting]);
13373
+ const refused = ["microphone", "camera"].flatMap((kind) => {
13374
+ const preview = region.preview[kind];
13375
+ const reason = region.permission[kind] === "denied" ? "denied" : preview.state === "unavailable" ? preview.unavailable?.reason ?? "failed" : null;
13376
+ return reason === null ? [] : [{ kind, reason }];
13377
+ });
13378
+ if (refused.length === 0) {
13379
+ if (!prompting || !waited) return null;
13380
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("p", { className: "mx-meet__panel-note", role: "status", "data-meet-notice": "permission-prompt", children: "Allow camera and microphone when your browser asks" });
13381
+ }
13382
+ const first = refused[0];
13383
+ const both = refused.length === 2 && refused[0].reason === refused[1].reason;
13384
+ const label = both ? deviceChipLabel("camera", first.reason).replace(/^Camera/, "Camera and mic") : refused.map((r) => deviceChipLabel(r.kind, r.reason)).join(" \xB7 ");
13385
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "mx-meet__warning", role: "status", "data-meet-notice": deviceNoticeCode(first.kind, first.reason), children: [
13386
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("strong", { children: label }),
13387
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("ol", { className: "mx-meet__fix-steps", children: deviceFixSteps(first.kind, first.reason, where).map((step) => /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("li", { children: step }, step)) }),
13388
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_design_system.Button, { type: "button", variant: "outline", size: "sm", onClick: onRetry, children: "Try again" })
13389
+ ] });
13390
+ }
13076
13391
  function KnockNotice({ onView }) {
13077
13392
  const { knocker, others, admit, dismiss } = useKnockNotice();
13078
13393
  if (knocker === null) return null;
@@ -13402,8 +13717,15 @@ function PreJoinBody({
13402
13717
  const [joining, setJoining] = (0, import_react10.useState)(false);
13403
13718
  const camera = devices?.selected.videoinput ?? host.devices.resolve("videoinput");
13404
13719
  const mic = devices?.selected.audioinput ?? host.devices.resolve("audioinput");
13405
- const preview = useCameraPreview(mode === "video" ? camera : void 0, effects.effects);
13406
- const micStream = useMicrophoneStream(microphone ? mic : "off");
13720
+ const { reportPreview } = useMediaSupervisor();
13721
+ const [attempt, setAttempt] = (0, import_react10.useState)(0);
13722
+ const preview = useCameraPreview(
13723
+ mode === "video" ? camera : void 0,
13724
+ effects.effects,
13725
+ (outcome) => reportPreview("camera", outcome),
13726
+ attempt
13727
+ );
13728
+ const micStream = useMicrophoneStream(microphone ? mic : "off", (outcome) => reportPreview("microphone", outcome), attempt);
13407
13729
  const level = useAudioLevel(micStream);
13408
13730
  const [speakerTesting, setSpeakerTesting] = (0, import_react10.useState)(false);
13409
13731
  (0, import_react10.useEffect)(() => {
@@ -13447,6 +13769,7 @@ function PreJoinBody({
13447
13769
  ] }),
13448
13770
  /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "mx-meet__prejoin-panel", children: [
13449
13771
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("h1", { className: "mx-meet__prejoin-title", children: "Ready to join?" }),
13772
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(PreviewStatus, { onRetry: () => setAttempt((n) => n + 1) }),
13450
13773
  notice?.kind === "removed" ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("p", { className: "mx-meet__warning", children: notice.text }) : null,
13451
13774
  devices?.labelsHidden === true ? /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("p", { className: "mx-meet__panel-note", children: [
13452
13775
  "Your browser is hiding device names until you allow camera and microphone access.",
@@ -13549,16 +13872,22 @@ function PreJoinBody({
13549
13872
  ] })
13550
13873
  ] });
13551
13874
  }
13552
- function useCameraPreview(deviceId2, effects) {
13875
+ function useCameraPreview(deviceId2, effects, report, attempt = 0) {
13876
+ const reportRef = (0, import_react10.useRef)(report);
13877
+ reportRef.current = report;
13553
13878
  const [preview, setPreview] = (0, import_react10.useState)(null);
13554
13879
  const [error, setError] = (0, import_react10.useState)(null);
13555
13880
  const current = (0, import_react10.useRef)(null);
13556
13881
  const initialEffect = (0, import_react10.useRef)(toVideoEffect(effects));
13557
13882
  initialEffect.current = toVideoEffect(effects);
13558
13883
  (0, import_react10.useEffect)(() => {
13559
- if (deviceId2 === void 0) return void 0;
13884
+ if (deviceId2 === void 0) {
13885
+ reportRef.current?.("off");
13886
+ return void 0;
13887
+ }
13560
13888
  let live = true;
13561
13889
  setError(null);
13890
+ reportRef.current?.("prompting");
13562
13891
  void createCameraPreview(deviceId2, initialEffect.current).then((created) => {
13563
13892
  if (!live) {
13564
13893
  created.stop();
@@ -13566,8 +13895,10 @@ function useCameraPreview(deviceId2, effects) {
13566
13895
  }
13567
13896
  current.current = created;
13568
13897
  setPreview(created);
13898
+ reportRef.current?.("on");
13569
13899
  }).catch((thrown) => {
13570
13900
  if (!live) return;
13901
+ reportRef.current?.({ error: thrown });
13571
13902
  const failure = thrown;
13572
13903
  const message = (failure.message ?? "").replace(/^meet\.[A-Za-z.()]+:\s*/, "");
13573
13904
  const sentence = message.length > 0 ? message.charAt(0).toUpperCase() + message.slice(1) : "";
@@ -13579,7 +13910,7 @@ function useCameraPreview(deviceId2, effects) {
13579
13910
  current.current = null;
13580
13911
  setPreview(null);
13581
13912
  };
13582
- }, [deviceId2]);
13913
+ }, [deviceId2, attempt]);
13583
13914
  return {
13584
13915
  preview,
13585
13916
  error,
@@ -13719,6 +14050,14 @@ function MeetSkinRoom({
13719
14050
  }
13720
14051
  function ShareNotice() {
13721
14052
  const { share, actions } = useMeetCore();
14053
+ const [soundHintClosed, setSoundHintClosed] = (0, import_react10.useState)(null);
14054
+ if (share.state === "presenting" && share.audio === false && soundHintClosed !== share.at) {
14055
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("p", { className: "mx-meet__banner", role: "status", "data-meet-notice": "share-audio-missing", children: [
14056
+ "Sound not shared",
14057
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "mx-meet__remedy", children: "To include sound, share a tab and turn on Also share tab audio" }),
14058
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("button", { type: "button", className: "mx-meet__link", onClick: () => setSoundHintClosed(share.at), children: "Dismiss" })
14059
+ ] });
14060
+ }
13722
14061
  if (share.state !== "refused") return null;
13723
14062
  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";
13724
14063
  return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("p", { className: "mx-meet__banner", role: "status", "data-meet-notice": "share-unavailable", children: [
@@ -13831,6 +14170,27 @@ function ExitScreen({ onLeave }) {
13831
14170
  ] });
13832
14171
  break;
13833
14172
  case "disconnected":
14173
+ if (failure?.stage === "join" && (failure.reason ?? null) === null) {
14174
+ title = "Can't connect to the meeting";
14175
+ line = connection === "offline" ? "You're offline. Check your internet connection." : "Check your network. A VPN, firewall or proxy may be blocking the call.";
14176
+ notice = "cannot-connect";
14177
+ actionsNode = /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(import_jsx_runtime6.Fragment, { children: [
14178
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
14179
+ import_design_system.Button,
14180
+ {
14181
+ type: "button",
14182
+ disabled: busy,
14183
+ onClick: () => {
14184
+ setBusy(true);
14185
+ void actions.rejoin().catch(() => void 0).finally(() => setBusy(false));
14186
+ },
14187
+ children: "Try again"
14188
+ }
14189
+ ),
14190
+ home
14191
+ ] });
14192
+ break;
14193
+ }
13834
14194
  title = "You lost your connection to the meeting";
13835
14195
  line = connection === "offline" ? "You're offline. You'll rejoin automatically when your internet is back." : connection === "server_unreachable" ? "Can't reach the meeting server. You'll rejoin automatically when it answers." : failure?.remedy ?? "Check your internet connection, then rejoin.";
13836
14196
  notice = "disconnected";