@ai-matrx/meet 0.7.3 → 0.7.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/index.cjs CHANGED
@@ -34,6 +34,7 @@ __export(src_exports, {
34
34
  CALL_INVITE_VERSION: () => CALL_INVITE_VERSION,
35
35
  CONSENT_NOTICE_ATTRIBUTE: () => CONSENT_NOTICE_ATTRIBUTE,
36
36
  DATA_TOPICS: () => DATA_TOPICS,
37
+ DEFAULT_EFFECTS: () => DEFAULT_EFFECTS,
37
38
  DEFAULT_PAGE_SIZE: () => DEFAULT_PAGE_SIZE,
38
39
  END_MEETING_NOT_SERVED: () => END_MEETING_NOT_SERVED,
39
40
  FILE_SESSION_PATH: () => FILE_SESSION_PATH,
@@ -51,6 +52,7 @@ __export(src_exports, {
51
52
  MEET_EVENTS: () => MEET_EVENTS,
52
53
  MEET_ROUTES: () => MEET_ROUTES,
53
54
  MEET_SCHEMA: () => MEET_SCHEMA,
55
+ MODERATION_ACTIONS: () => MODERATION_ACTIONS,
54
56
  MeetError: () => MeetError,
55
57
  NOTE_TAKER_IDENTITY_PREFIX: () => NOTE_TAKER_IDENTITY_PREFIX,
56
58
  NOTE_TAKER_METADATA_KEY: () => NOTE_TAKER_METADATA_KEY,
@@ -90,6 +92,7 @@ __export(src_exports, {
90
92
  createCallCenter: () => createCallCenter,
91
93
  createCallInviteHandler: () => createCallInviteHandler,
92
94
  createDeviceManager: () => createDeviceManager,
95
+ createInRoomClient: () => createInRoomClient,
93
96
  createLiveKitDriver: () => createLiveKitDriver,
94
97
  createMeetAi: () => createMeetAi,
95
98
  createMeetApi: () => createMeetApi,
@@ -102,6 +105,7 @@ __export(src_exports, {
102
105
  createRoomEngine: () => createRoomEngine,
103
106
  createTokenClient: () => createTokenClient,
104
107
  createWebDeviceStorage: () => createWebDeviceStorage,
108
+ createWebEffectsStorage: () => createWebEffectsStorage,
105
109
  createWebRoomTokenStorage: () => createWebRoomTokenStorage,
106
110
  displayNameFor: () => displayNameFor,
107
111
  fileDownloadPath: () => fileDownloadPath,
@@ -363,7 +367,16 @@ var MEET_ROUTES = {
363
367
  */
364
368
  meetingAnnounce: "/api/v1/meet/meetings/{meeting_id}/announce",
365
369
  /** Put a one-off meeting on the caller's own Google Calendar (gated by the server). */
366
- meetingGoogleCalendar: "/api/v1/meet/meetings/{meeting_id}/google-calendar"
370
+ meetingGoogleCalendar: "/api/v1/meet/meetings/{meeting_id}/google-calendar",
371
+ /**
372
+ * Meet wave 2: EVERY in-meeting host power (mute, stop video, mute all, ask to
373
+ * unmute, spotlight, lower hands, co-host, hand over, rename, remove, report).
374
+ * Decided by the server from the meeting itself; a guest's pass rides
375
+ * `x-meet-room-token`.
376
+ */
377
+ moderate: "/api/v1/meet/moderate",
378
+ /** Meet wave 2: the SAVED chat — `POST` sends to everyone, `GET ?meeting_id=` reads history. */
379
+ chat: "/api/v1/meet/chat"
367
380
  };
368
381
  function meetingPath(template, meetingId) {
369
382
  return template.replace("{meeting_id}", encodeURIComponent(meetingId));
@@ -2081,7 +2094,19 @@ var ROOM_EVENTS = {
2081
2094
  * production 2026-09-09 the guest lane showed no indicator at all while the
2082
2095
  * host recorded (MRI-C8).
2083
2096
  */
2084
- recording: "matrx.meet.recording"
2097
+ recording: "matrx.meet.recording",
2098
+ /**
2099
+ * A chat message to everyone was SAVED (Meet wave 2). Payload
2100
+ * `{ message: { id, meeting_id, sender_identity, sender_name, body, created_at } }`.
2101
+ */
2102
+ chat: "matrx.meet.chat",
2103
+ /**
2104
+ * A host or co-host acted (Meet wave 2). Payload `{ meeting_id, kind, … }`:
2105
+ * `muted {source, by}` · `ask-unmute {by}` · `removed {by}` — addressed to the
2106
+ * person concerned; `lower-hands {identities: [...] | "*"}` · `role {identity,
2107
+ * role}` — to everyone.
2108
+ */
2109
+ moderation: "matrx.meet.moderation"
2085
2110
  };
2086
2111
  function isRoomEventTopic(topic) {
2087
2112
  return topic.startsWith(ROOM_EVENT_PREFIX);
@@ -2117,6 +2142,10 @@ function applyRoomEvent(args) {
2117
2142
  return applyEnded(store, payload);
2118
2143
  case ROOM_EVENTS.recording:
2119
2144
  return applyRecording(store, payload);
2145
+ case ROOM_EVENTS.chat:
2146
+ return applyChat(store, payload);
2147
+ case ROOM_EVENTS.moderation:
2148
+ return applyModeration(store, payload, now);
2120
2149
  default:
2121
2150
  return ignored(`${topic} is not known to this version of @ai-matrx/meet`);
2122
2151
  }
@@ -2208,6 +2237,101 @@ function applyEnded(store, payload) {
2208
2237
  store.patch({ meeting: { ...meeting, endedAt } });
2209
2238
  return HANDLED;
2210
2239
  }
2240
+ function chatFromRow(row) {
2241
+ const id = text2(row["id"]);
2242
+ const identity = text2(row["sender_identity"]);
2243
+ const body = text2(row["body"]);
2244
+ if (id === null || identity === null || body === null) return null;
2245
+ return {
2246
+ id,
2247
+ identity: asParticipantIdentity(identity),
2248
+ displayName: text2(row["sender_name"]) ?? "Participant",
2249
+ text: body,
2250
+ at: text2(row["created_at"]) ?? (/* @__PURE__ */ new Date()).toISOString(),
2251
+ // `false` only from a server whose chat table has not landed: the room heard
2252
+ // it, nobody will see it later — and the line says so.
2253
+ saved: row["saved"] !== false
2254
+ };
2255
+ }
2256
+ function applyChat(store, payload) {
2257
+ const row = object(payload["message"]);
2258
+ if (row === null) return ignored("a chat event with no `message` row");
2259
+ const meeting = store.snapshot().meeting;
2260
+ if (meeting !== null && text2(row["meeting_id"]) !== null && row["meeting_id"] !== meeting.id) {
2261
+ return ignored("a chat message for a different meeting");
2262
+ }
2263
+ const message = chatFromRow(row);
2264
+ if (message === null) return ignored("a chat row with no id, sender or body");
2265
+ store.addChat(message);
2266
+ return HANDLED;
2267
+ }
2268
+ function moderationText(kind, payload) {
2269
+ const by = text2(payload["by"]) ?? "The host";
2270
+ switch (kind) {
2271
+ case "muted": {
2272
+ const source = text2(payload["source"]) ?? "microphone";
2273
+ return source === "microphone" ? `${by} muted you.` : source === "camera" ? `${by} turned off your camera.` : `${by} stopped your screen share.`;
2274
+ }
2275
+ case "ask-unmute":
2276
+ return `${by} is asking you to unmute.`;
2277
+ case "removed":
2278
+ return `${by} removed you from this meeting.`;
2279
+ default:
2280
+ return null;
2281
+ }
2282
+ }
2283
+ function applyModeration(store, payload, now) {
2284
+ const snapshot = store.snapshot();
2285
+ const meetingId = text2(payload["meeting_id"]);
2286
+ if (snapshot.meeting !== null && meetingId !== null && meetingId !== snapshot.meeting.id) {
2287
+ return ignored("a moderation event for a different meeting");
2288
+ }
2289
+ const kind = text2(payload["kind"]);
2290
+ switch (kind) {
2291
+ case "lower-hands": {
2292
+ const raw = payload["identities"];
2293
+ const everyone = raw === "*";
2294
+ const named = new Set(
2295
+ Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []
2296
+ );
2297
+ for (const participant of snapshot.participants) {
2298
+ if (participant.handRaised && (everyone || named.has(participant.identity))) {
2299
+ store.setHand(participant.identity, false);
2300
+ }
2301
+ }
2302
+ return HANDLED;
2303
+ }
2304
+ case "role": {
2305
+ const identity = text2(payload["identity"]);
2306
+ const role = text2(payload["role"]);
2307
+ if (identity === null || role === null || !ROLES.includes(role)) {
2308
+ return ignored("a role event with no identity or an unknown role");
2309
+ }
2310
+ if (identity === snapshot.localIdentity) {
2311
+ store.patch({
2312
+ grants: { ...snapshot.grants, role },
2313
+ moderationNotice: {
2314
+ kind: "role",
2315
+ text: role === "host" ? "You are now the host." : role === "cohost" ? "You are now a co-host." : "You are no longer a co-host.",
2316
+ at: now()
2317
+ }
2318
+ });
2319
+ }
2320
+ return HANDLED;
2321
+ }
2322
+ case "muted":
2323
+ case "ask-unmute":
2324
+ case "removed": {
2325
+ const sentence = moderationText(kind, payload);
2326
+ if (sentence === null) return ignored("a moderation event with no sentence");
2327
+ store.patch({ moderationNotice: { kind, text: sentence, at: now() } });
2328
+ return HANDLED;
2329
+ }
2330
+ default:
2331
+ return ignored(`a moderation kind this version does not know: ${String(kind)}`);
2332
+ }
2333
+ }
2334
+ var ROLES = ["host", "cohost", "participant", "guest"];
2211
2335
 
2212
2336
  // src/core/devices.ts
2213
2337
  var KINDS = ["audioinput", "videoinput", "audiooutput"];
@@ -2540,7 +2664,8 @@ function computeStage(args) {
2540
2664
  const ordered = orderParticipants(args.participants, args.activeSpeakers);
2541
2665
  const sharer = ordered.find((participant) => participant.screenSharing) ?? null;
2542
2666
  const pinnedParticipant = args.pinned === null ? null : ordered.find((participant) => participant.identity === args.pinned) ?? null;
2543
- const feature = sharer !== null ? sharer : args.layout === "grid" ? null : pinnedParticipant ?? ordered[0] ?? null;
2667
+ const spotlit = args.spotlight == null ? null : ordered.find((participant) => participant.identity === args.spotlight) ?? null;
2668
+ const feature = sharer !== null ? sharer : pinnedParticipant !== null && args.layout !== "grid" ? pinnedParticipant : spotlit !== null ? spotlit : args.layout === "grid" ? null : ordered[0] ?? null;
2544
2669
  const rest = feature === null ? ordered : ordered.filter((participant) => participant.identity !== feature.identity);
2545
2670
  const pageCount = Math.max(1, Math.ceil(rest.length / pageSize));
2546
2671
  const page = Math.min(Math.max(0, args.page ?? 0), pageCount - 1);
@@ -2566,8 +2691,7 @@ function gridColumns(count2) {
2566
2691
  var DATA_TOPICS = {
2567
2692
  hand: "mx.hand",
2568
2693
  reaction: "mx.reaction",
2569
- chat: "mx.chat",
2570
- hostAction: "mx.host"
2694
+ chat: "mx.chat"
2571
2695
  };
2572
2696
  var TRANSCRIPTION_TOPIC = "lk.transcription";
2573
2697
  var TRANSCRIPTION_ATTRIBUTES = {
@@ -2628,11 +2752,48 @@ function snapshotParticipant(participant, isLocal) {
2628
2752
  screenSharing: participant.isScreenShareEnabled
2629
2753
  };
2630
2754
  }
2755
+ var DEFAULT_AUDIO_PROCESSING = {
2756
+ noiseSuppression: true,
2757
+ echoCancellation: true,
2758
+ autoGainControl: true
2759
+ };
2760
+ function videoEffectsSupported() {
2761
+ const scope = globalThis;
2762
+ if (typeof scope.OffscreenCanvas === "undefined" || scope.document === void 0) return false;
2763
+ try {
2764
+ return scope.document.createElement("canvas").getContext("webgl2") !== null;
2765
+ } catch {
2766
+ return false;
2767
+ }
2768
+ }
2769
+ async function applyVideoEffect(target, effect) {
2770
+ const { Track } = await import("livekit-client");
2771
+ const track = target.localParticipant.getTrackPublication(Track.Source.Camera)?.track;
2772
+ if (track === void 0) return;
2773
+ try {
2774
+ if (effect.kind === "none") {
2775
+ await track.stopProcessor();
2776
+ return;
2777
+ }
2778
+ const processors = await import("@livekit/track-processors");
2779
+ const processor = effect.kind === "blur" ? processors.BackgroundBlur(effect.radius ?? 12) : processors.VirtualBackground(effect.imageUrl);
2780
+ await track.setProcessor(processor);
2781
+ } catch (error) {
2782
+ throw new MeetError(
2783
+ "media-unsupported",
2784
+ "The background effect could not start on this camera",
2785
+ "Background effects need a browser with WebGL2 (current Chrome, Edge, Firefox or Safari). Your video continues without the effect.",
2786
+ error
2787
+ );
2788
+ }
2789
+ }
2631
2790
  var encoder = new TextEncoder();
2632
2791
  var decoder = new TextDecoder();
2633
2792
  function createLiveKitDriver(handlers, options = {}) {
2634
2793
  let room = null;
2635
2794
  let projectRoster = null;
2795
+ let audioCapture = DEFAULT_AUDIO_PROCESSING;
2796
+ let videoEffect = { kind: "none" };
2636
2797
  async function load() {
2637
2798
  if (room !== null) return room;
2638
2799
  let module2;
@@ -2661,7 +2822,9 @@ function createLiveKitDriver(handlers, options = {}) {
2661
2822
  handlers.participants([local, ...remotes]);
2662
2823
  };
2663
2824
  projectRoster = roster;
2664
- created.on(RoomEvent.ParticipantConnected, roster).on(RoomEvent.ParticipantDisconnected, roster).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.ActiveSpeakersChanged, (speakers) => {
2825
+ created.on(RoomEvent.ParticipantConnected, roster).on(RoomEvent.ParticipantDisconnected, roster).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) => {
2826
+ handlers.roomMetadata?.(metadata ?? "");
2827
+ }).on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
2665
2828
  handlers.activeSpeakers(speakers.map((speaker) => speaker.identity));
2666
2829
  }).on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
2667
2830
  handlers.quality(participant.identity, mapQuality(quality));
@@ -2738,6 +2901,7 @@ function createLiveKitDriver(handlers, options = {}) {
2738
2901
  throw normalizeDeviceError(error, "meet.connect");
2739
2902
  }
2740
2903
  projectRoster?.();
2904
+ handlers.roomMetadata?.(target.metadata ?? "");
2741
2905
  },
2742
2906
  async disconnect() {
2743
2907
  if (room === null) return;
@@ -2752,14 +2916,19 @@ function createLiveKitDriver(handlers, options = {}) {
2752
2916
  },
2753
2917
  async setMicrophoneEnabled(enabled) {
2754
2918
  try {
2755
- await requireRoom("setMicrophoneEnabled").localParticipant.setMicrophoneEnabled(enabled);
2919
+ await requireRoom("setMicrophoneEnabled").localParticipant.setMicrophoneEnabled(
2920
+ enabled,
2921
+ audioCapture
2922
+ );
2756
2923
  } catch (error) {
2757
2924
  throw normalizeDeviceError(error, "meet.setMicrophoneEnabled");
2758
2925
  }
2759
2926
  },
2760
2927
  async setCameraEnabled(enabled) {
2761
2928
  try {
2762
- await requireRoom("setCameraEnabled").localParticipant.setCameraEnabled(enabled);
2929
+ const target = requireRoom("setCameraEnabled");
2930
+ await target.localParticipant.setCameraEnabled(enabled);
2931
+ if (enabled && videoEffect.kind !== "none") await applyVideoEffect(target, videoEffect);
2763
2932
  } catch (error) {
2764
2933
  throw normalizeDeviceError(error, "meet.setCameraEnabled");
2765
2934
  }
@@ -2771,14 +2940,40 @@ function createLiveKitDriver(handlers, options = {}) {
2771
2940
  throw normalizeDeviceError(error, "meet.setScreenShareEnabled");
2772
2941
  }
2773
2942
  },
2774
- async publishData(topic, payload) {
2943
+ async publishData(topic, payload, to) {
2775
2944
  await requireRoom("publishData").localParticipant.publishData(
2776
2945
  // `reliable` is not optional for a control plane: a dropped
2777
2946
  // "hand lowered" leaves a hand up on every other screen forever.
2778
2947
  encoder.encode(payload),
2779
- { reliable: true, topic }
2948
+ to === void 0 || to.length === 0 ? { reliable: true, topic } : { reliable: true, topic, destinationIdentities: [...to] }
2780
2949
  );
2781
2950
  },
2951
+ async setAudioProcessing(processing) {
2952
+ audioCapture = { ...audioCapture, ...processing };
2953
+ const target = room;
2954
+ if (target === null) return;
2955
+ const { Track } = await import("livekit-client");
2956
+ const publication = target.localParticipant.getTrackPublication(Track.Source.Microphone);
2957
+ const track = publication?.track;
2958
+ if (track === void 0 || publication?.isMuted === true) return;
2959
+ try {
2960
+ await track.restartTrack({
2961
+ ...audioCapture,
2962
+ ...target.getActiveDevice("audioinput") !== void 0 ? { deviceId: target.getActiveDevice("audioinput") } : {}
2963
+ });
2964
+ } catch (error) {
2965
+ throw normalizeDeviceError(error, "meet.setAudioProcessing");
2966
+ }
2967
+ },
2968
+ async setVideoEffect(effect) {
2969
+ videoEffect = effect;
2970
+ const target = room;
2971
+ if (target === null) return;
2972
+ await applyVideoEffect(target, effect);
2973
+ },
2974
+ supportsVideoEffects() {
2975
+ return videoEffectsSupported();
2976
+ },
2782
2977
  attach(identity, kind, element) {
2783
2978
  const target = requireRoom("attach");
2784
2979
  const source = sources?.[kind];
@@ -3001,6 +3196,11 @@ function listenOnlyNotice(media) {
3001
3196
  }
3002
3197
 
3003
3198
  // src/core/store.ts
3199
+ var DEFAULT_EFFECTS = {
3200
+ noiseSuppression: true,
3201
+ background: "none",
3202
+ backgroundImage: null
3203
+ };
3004
3204
  var NO_GRANTS = {
3005
3205
  role: "participant",
3006
3206
  canRecord: false,
@@ -3023,7 +3223,12 @@ var EMPTY = {
3023
3223
  recordingSource: "local",
3024
3224
  recordingId: null,
3025
3225
  chat: [],
3226
+ chatLoaded: false,
3026
3227
  reactions: [],
3228
+ spotlight: null,
3229
+ moderationNotice: null,
3230
+ actionNotice: null,
3231
+ effects: DEFAULT_EFFECTS,
3027
3232
  transcript: [],
3028
3233
  notes: [],
3029
3234
  names: {},
@@ -3062,6 +3267,7 @@ function createMeetStore(initial = {}) {
3062
3267
  setParticipants(participants) {
3063
3268
  const names = learnNames(state.names, participants);
3064
3269
  const hands = new Map(state.participants.map((p) => [p.identity, p.handRaised]));
3270
+ const handTimes = new Map(state.participants.map((p) => [p.identity, p.handRaisedAt ?? null]));
3065
3271
  const quality = new Map(state.participants.map((p) => [p.identity, p.quality]));
3066
3272
  commit({
3067
3273
  ...state,
@@ -3075,6 +3281,7 @@ function createMeetStore(initial = {}) {
3075
3281
  // name book. `roster-names.test.ts` fails if a token ever gets past it.
3076
3282
  displayName: displayNameFor(names, participant.identity, participant.displayName),
3077
3283
  handRaised: hands.get(participant.identity) ?? participant.handRaised,
3284
+ handRaisedAt: handTimes.get(participant.identity) ?? participant.handRaisedAt ?? null,
3078
3285
  quality: participant.quality === "unknown" ? quality.get(participant.identity) ?? "unknown" : participant.quality
3079
3286
  }))
3080
3287
  });
@@ -3097,11 +3304,17 @@ function createMeetStore(initial = {}) {
3097
3304
  )
3098
3305
  });
3099
3306
  },
3100
- setHand(identity, raised) {
3307
+ setHand(identity, raised, at) {
3101
3308
  commit({
3102
3309
  ...state,
3103
3310
  participants: state.participants.map(
3104
- (participant) => participant.identity === identity ? { ...participant, handRaised: raised } : participant
3311
+ (participant) => participant.identity === identity ? {
3312
+ ...participant,
3313
+ handRaised: raised,
3314
+ // A hand that is ALREADY up keeps its place in the queue when
3315
+ // it is re-announced (to a late joiner): the first time wins.
3316
+ handRaisedAt: raised ? participant.handRaised && participant.handRaisedAt != null ? participant.handRaisedAt : at ?? Date.now() : null
3317
+ } : participant
3105
3318
  )
3106
3319
  });
3107
3320
  },
@@ -3111,6 +3324,31 @@ function createMeetStore(initial = {}) {
3111
3324
  const names = learnName(state.names, message.identity, message.displayName);
3112
3325
  commit({ ...state, chat: chat.slice(-CHAT_LIMIT), names });
3113
3326
  },
3327
+ loadChat(messages) {
3328
+ const known = new Set(state.chat.map((message) => message.id));
3329
+ const merged = [...messages.filter((message) => !known.has(message.id)), ...state.chat].sort(
3330
+ (a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0
3331
+ );
3332
+ const names = learnNames(
3333
+ state.names,
3334
+ messages.map((message) => ({ identity: message.identity, displayName: message.displayName }))
3335
+ );
3336
+ commit({ ...state, chat: merged.slice(-CHAT_LIMIT), chatLoaded: true, names });
3337
+ },
3338
+ settleChat(localId, next) {
3339
+ const index = state.chat.findIndex((message) => message.id === localId);
3340
+ if (index === -1) return;
3341
+ const current = state.chat[index];
3342
+ let replacement;
3343
+ if ("failed" in next && typeof next.failed === "string" && !("id" in next)) {
3344
+ replacement = { ...current, failed: next.failed };
3345
+ } else {
3346
+ replacement = next;
3347
+ }
3348
+ const duplicate = replacement.id !== localId && state.chat.some((message) => message.id === replacement.id);
3349
+ const chat = duplicate ? state.chat.filter((message) => message.id !== localId) : state.chat.map((message, position) => position === index ? replacement : message);
3350
+ commit({ ...state, chat });
3351
+ },
3114
3352
  addReaction(reaction) {
3115
3353
  commit({ ...state, reactions: [...state.reactions, reaction] });
3116
3354
  },
@@ -3201,7 +3439,7 @@ function createMeetStore(initial = {}) {
3201
3439
  commit({ ...state, media: { ...state.media, [kind]: next } });
3202
3440
  },
3203
3441
  reset() {
3204
- commit({ ...EMPTY });
3442
+ commit({ ...EMPTY, effects: state.effects });
3205
3443
  }
3206
3444
  };
3207
3445
  }
@@ -3833,7 +4071,37 @@ function createRoomEngine(options) {
3833
4071
  const handlers = {
3834
4072
  participants(list) {
3835
4073
  const joinedAt = new Date(now()).toISOString();
4074
+ const before = new Set(store.snapshot().participants.map((p) => p.identity));
3836
4075
  store.setParticipants(list.map((entry) => projectParticipant(entry, joinedAt)));
4076
+ const snapshot = store.snapshot();
4077
+ const newcomer = snapshot.participants.some(
4078
+ (participant) => !participant.isLocal && !before.has(participant.identity)
4079
+ );
4080
+ const me = snapshot.participants.find((p) => p.identity === snapshot.localIdentity);
4081
+ if (newcomer && before.size > 0 && me?.handRaised === true) {
4082
+ void publish(DATA_TOPICS.hand, { raised: true, raisedAt: me.handRaisedAt ?? now() }).catch(
4083
+ () => void 0
4084
+ );
4085
+ }
4086
+ },
4087
+ roomMetadata(metadata) {
4088
+ let spotlight = null;
4089
+ if (metadata.length > 0) {
4090
+ try {
4091
+ const parsed = JSON.parse(metadata);
4092
+ if (typeof parsed === "object" && parsed !== null) {
4093
+ const value = parsed.spotlight;
4094
+ if (typeof value === "string" && value.length > 0) spotlight = asParticipantIdentity(value);
4095
+ }
4096
+ } catch {
4097
+ report(
4098
+ "warn",
4099
+ "meet: the room's metadata is not JSON, so no spotlight was read from it",
4100
+ "Only aidream writes room metadata; check services/meet/moderation.py."
4101
+ );
4102
+ }
4103
+ }
4104
+ if (store.snapshot().spotlight !== spotlight) store.patch({ spotlight });
3837
4105
  },
3838
4106
  activeSpeakers(identities) {
3839
4107
  store.setSpeaking(identities.map(asParticipantIdentity));
@@ -3937,7 +4205,11 @@ function createRoomEngine(options) {
3937
4205
  const sender = asParticipantIdentity(from ?? "");
3938
4206
  switch (topic) {
3939
4207
  case DATA_TOPICS.hand:
3940
- store.setHand(sender, envelope.raised === true);
4208
+ store.setHand(
4209
+ sender,
4210
+ envelope.raised === true,
4211
+ typeof envelope.raisedAt === "number" ? envelope.raisedAt : now()
4212
+ );
3941
4213
  return;
3942
4214
  case DATA_TOPICS.reaction:
3943
4215
  if (typeof envelope.emoji === "string") {
@@ -3951,12 +4223,14 @@ function createRoomEngine(options) {
3951
4223
  return;
3952
4224
  case DATA_TOPICS.chat:
3953
4225
  if (typeof envelope.text === "string") {
4226
+ const local = store.snapshot().localIdentity;
3954
4227
  store.addChat({
3955
4228
  id: envelope.id ?? `${sender}-${now()}`,
3956
4229
  identity: sender,
3957
4230
  displayName: envelope.name ?? sender,
3958
4231
  text: envelope.text,
3959
- at: envelope.at ?? new Date(now()).toISOString()
4232
+ at: envelope.at ?? new Date(now()).toISOString(),
4233
+ ...typeof envelope.to === "string" && local !== null ? { to: local } : {}
3960
4234
  });
3961
4235
  }
3962
4236
  return;
@@ -4002,6 +4276,24 @@ function createRoomEngine(options) {
4002
4276
  store.patch({ phase: "connected", failure: null });
4003
4277
  store.setLocalTrack("microphone", IDLE_TRACK);
4004
4278
  store.setLocalTrack("camera", IDLE_TRACK);
4279
+ const effects = options.effectsStorage?.load() ?? store.snapshot().effects;
4280
+ store.patch({ effects });
4281
+ if (!effects.noiseSuppression || effects.background !== "none") {
4282
+ try {
4283
+ await applyEffects(created, effects);
4284
+ } catch {
4285
+ }
4286
+ }
4287
+ for (const kind of ["audioinput", "videoinput", "audiooutput"]) {
4288
+ const chosen = options.resolveDevice?.(kind) ?? null;
4289
+ if (chosen === null || chosen === "default") continue;
4290
+ try {
4291
+ await created.switchDevice(kind, chosen);
4292
+ } catch (error) {
4293
+ const normalized = normalizeDeviceError(error, `meet.switchDevice(${kind})`);
4294
+ report("warn", normalized.message, normalized.remedy);
4295
+ }
4296
+ }
4005
4297
  if (request.mode === "video") {
4006
4298
  await enableTrack(created, "camera", true);
4007
4299
  }
@@ -4024,9 +4316,30 @@ function createRoomEngine(options) {
4024
4316
  return state;
4025
4317
  }
4026
4318
  }
4027
- async function publish(topic, body) {
4319
+ async function publish(topic, body, to) {
4028
4320
  if (driver === null) return;
4029
- await driver.publishData(topic, JSON.stringify(body));
4321
+ await driver.publishData(topic, JSON.stringify(body), to);
4322
+ }
4323
+ async function applyEffects(target, effects) {
4324
+ try {
4325
+ await target.setAudioProcessing?.({
4326
+ noiseSuppression: effects.noiseSuppression,
4327
+ echoCancellation: true,
4328
+ autoGainControl: true
4329
+ });
4330
+ } catch (error) {
4331
+ report("warn", `meet: noise suppression could not be applied: ${String(error)}`, "The microphone keeps working without it.");
4332
+ }
4333
+ if (target.setVideoEffect === void 0) return;
4334
+ const effect = effects.background === "blur" ? { kind: "blur" } : effects.background === "image" && effects.backgroundImage !== null ? { kind: "image", imageUrl: effects.backgroundImage } : { kind: "none" };
4335
+ if (effect.kind !== "none" && target.supportsVideoEffects?.() === false) return;
4336
+ try {
4337
+ await target.setVideoEffect(effect);
4338
+ } catch (error) {
4339
+ const normalized = normalizeMeetError(error, "meet.setVideoEffect");
4340
+ report("warn", normalized.message, normalized.remedy);
4341
+ throw normalized;
4342
+ }
4030
4343
  }
4031
4344
  return {
4032
4345
  join(request) {
@@ -4082,7 +4395,7 @@ function createRoomEngine(options) {
4082
4395
  async raiseHand(raised) {
4083
4396
  const local = store.snapshot().localIdentity;
4084
4397
  if (local !== null) store.setHand(local, raised);
4085
- await publish(DATA_TOPICS.hand, { raised });
4398
+ await publish(DATA_TOPICS.hand, { raised, raisedAt: now() });
4086
4399
  },
4087
4400
  async react(emoji) {
4088
4401
  const local = store.snapshot().localIdentity;
@@ -4092,32 +4405,41 @@ function createRoomEngine(options) {
4092
4405
  }
4093
4406
  await publish(DATA_TOPICS.reaction, { id, emoji });
4094
4407
  },
4095
- async sendChat(text3) {
4408
+ async sendPrivateChat(to, text3) {
4096
4409
  const trimmed = text3.trim();
4097
4410
  if (trimmed.length === 0) return;
4098
4411
  const local = store.snapshot().localIdentity;
4099
4412
  const id = `${local ?? "local"}-${now()}`;
4100
4413
  const at = new Date(now()).toISOString();
4101
4414
  if (local !== null) {
4102
- store.addChat({
4103
- id,
4104
- identity: local,
4105
- displayName: identity.displayName,
4106
- text: trimmed,
4107
- at
4108
- });
4415
+ store.addChat({ id, identity: local, displayName: identity.displayName, text: trimmed, at, to });
4109
4416
  }
4110
- await publish(DATA_TOPICS.chat, { id, text: trimmed, at, name: identity.displayName });
4417
+ await publish(
4418
+ DATA_TOPICS.chat,
4419
+ { id, text: trimmed, at, name: identity.displayName, to },
4420
+ [to]
4421
+ );
4111
4422
  },
4112
- async publishHostAction(action) {
4113
- if (!isHostRole(effectiveRole(store.snapshot()))) {
4114
- throw new MeetError(
4115
- "forbidden",
4116
- `meet.hostAction(${action.kind}): this participant is not a host`,
4117
- "Host powers come from the server-minted token metadata. Hide the control instead of rendering a dead button (no dead ends)."
4118
- );
4423
+ async setEffects(next) {
4424
+ const previous = store.snapshot().effects;
4425
+ const merged = { ...previous, ...next };
4426
+ store.patch({ effects: merged });
4427
+ options.effectsStorage?.save(merged);
4428
+ if (driver === null) return;
4429
+ try {
4430
+ await applyEffects(driver, merged);
4431
+ } catch {
4432
+ const reverted = { ...merged, background: previous.background, backgroundImage: previous.backgroundImage };
4433
+ store.patch({ effects: reverted });
4434
+ options.effectsStorage?.save(reverted);
4119
4435
  }
4120
- await publish(DATA_TOPICS.hostAction, { ...action });
4436
+ },
4437
+ supportsVideoEffects() {
4438
+ return driver?.supportsVideoEffects?.() ?? false;
4439
+ },
4440
+ async switchDevice(kind, deviceId) {
4441
+ if (driver === null) return;
4442
+ await driver.switchDevice(kind, deviceId);
4121
4443
  },
4122
4444
  pin(who) {
4123
4445
  store.patch({ pinned: who, layout: who === null ? "grid" : "speaker" });
@@ -4148,6 +4470,192 @@ function hostUserIds(participants) {
4148
4470
  return participants.filter((participant) => participant.role === "host" && participant.userId !== null).map((participant) => participant.userId);
4149
4471
  }
4150
4472
 
4473
+ // src/core/effects.ts
4474
+ function coerce(value) {
4475
+ if (typeof value !== "object" || value === null) return DEFAULT_EFFECTS;
4476
+ const row = value;
4477
+ const background = row["background"] === "blur" || row["background"] === "image" ? row["background"] : "none";
4478
+ const backgroundImage = typeof row["backgroundImage"] === "string" ? row["backgroundImage"] : null;
4479
+ return {
4480
+ noiseSuppression: row["noiseSuppression"] !== false,
4481
+ background: background === "image" && backgroundImage === null ? "none" : background,
4482
+ backgroundImage
4483
+ };
4484
+ }
4485
+ function createWebEffectsStorage(key = "mx.meet.effects") {
4486
+ let memory = DEFAULT_EFFECTS;
4487
+ const storage = globalThis.localStorage;
4488
+ return {
4489
+ load() {
4490
+ if (storage === void 0) return memory;
4491
+ try {
4492
+ const raw = storage.getItem(key);
4493
+ return raw === null ? memory : coerce(JSON.parse(raw));
4494
+ } catch {
4495
+ return memory;
4496
+ }
4497
+ },
4498
+ save(effects) {
4499
+ memory = effects;
4500
+ try {
4501
+ storage?.setItem(key, JSON.stringify(effects));
4502
+ } catch {
4503
+ }
4504
+ }
4505
+ };
4506
+ }
4507
+
4508
+ // src/core/in-room.ts
4509
+ var MODERATION_ACTIONS = [
4510
+ "mute",
4511
+ "stop_video",
4512
+ "stop_screen",
4513
+ "mute_all",
4514
+ "ask_unmute",
4515
+ "ask_all_unmute",
4516
+ "spotlight",
4517
+ "lower_hand",
4518
+ "lower_all_hands",
4519
+ "make_cohost",
4520
+ "revoke_cohost",
4521
+ "transfer_host",
4522
+ "rename",
4523
+ "remove",
4524
+ "report"
4525
+ ];
4526
+ function createInRoomClient(options) {
4527
+ const { api, store } = options;
4528
+ const now = options.now ?? (() => Date.now());
4529
+ function meetingId(operation) {
4530
+ const meeting = store.snapshot().meeting;
4531
+ if (meeting === null) {
4532
+ throw new MeetError(
4533
+ "not-found",
4534
+ `${operation}: this room has no meeting record`,
4535
+ "Moderation and saved chat belong to a meeting. An ad-hoc call has neither."
4536
+ );
4537
+ }
4538
+ return meeting.id;
4539
+ }
4540
+ function headers() {
4541
+ const token = options.resolveRoomToken();
4542
+ return token === null ? void 0 : { [ROOM_TOKEN_HEADER]: token };
4543
+ }
4544
+ async function refusal2(response, operation, remedy) {
4545
+ let body = null;
4546
+ try {
4547
+ body = await response.json();
4548
+ } catch {
4549
+ body = null;
4550
+ }
4551
+ const said = readServerRefusal(body);
4552
+ return new MeetError(
4553
+ response.status === 403 || response.status === 401 ? "forbidden" : response.status === 404 ? "not-found" : response.status === 409 ? "conflict" : "transport",
4554
+ said?.reason ?? `${operation}: the server answered ${response.status}`,
4555
+ said?.remedy !== void 0 && said.remedy.length > 0 ? said.remedy : remedy
4556
+ );
4557
+ }
4558
+ return {
4559
+ async moderate(request) {
4560
+ const operation = `meet.moderate(${request.action})`;
4561
+ const hdrs = headers();
4562
+ const response = await api.request({
4563
+ path: MEET_ROUTES.moderate,
4564
+ method: "POST",
4565
+ operation,
4566
+ body: {
4567
+ meeting_id: meetingId(operation),
4568
+ action: request.action,
4569
+ ...request.identity != null ? { identity: request.identity } : {},
4570
+ ...request.name !== void 0 ? { name: request.name } : {},
4571
+ ...request.reason !== void 0 ? { reason: request.reason } : {}
4572
+ },
4573
+ ...hdrs !== void 0 ? { headers: hdrs } : {}
4574
+ });
4575
+ if (!response.ok) {
4576
+ throw await refusal2(
4577
+ response,
4578
+ operation,
4579
+ "Nothing was changed. The server decides host powers from the meeting itself."
4580
+ );
4581
+ }
4582
+ const payload = await response.json();
4583
+ return {
4584
+ action: request.action,
4585
+ affected: Array.isArray(payload["affected"]) ? payload["affected"].filter((value) => typeof value === "string") : [],
4586
+ message: typeof payload["message"] === "string" ? payload["message"] : "Done."
4587
+ };
4588
+ },
4589
+ async sendChat(text3) {
4590
+ const body = text3.trim();
4591
+ if (body.length === 0) return;
4592
+ const operation = "meet.sendChat";
4593
+ const id = meetingId(operation);
4594
+ const snapshot = store.snapshot();
4595
+ const me = snapshot.participants.find((p) => p.identity === snapshot.localIdentity);
4596
+ const localId = `pending-${now()}`;
4597
+ const pending = {
4598
+ id: localId,
4599
+ identity: snapshot.localIdentity ?? "local",
4600
+ displayName: me?.displayName ?? "You",
4601
+ text: body,
4602
+ at: new Date(now()).toISOString(),
4603
+ saved: false
4604
+ };
4605
+ store.addChat(pending);
4606
+ const hdrs = headers();
4607
+ let response;
4608
+ try {
4609
+ response = await api.request({
4610
+ path: MEET_ROUTES.chat,
4611
+ method: "POST",
4612
+ operation,
4613
+ body: { meeting_id: id, body },
4614
+ ...hdrs !== void 0 ? { headers: hdrs } : {}
4615
+ });
4616
+ } catch (error) {
4617
+ store.settleChat(localId, { failed: "Not sent \u2014 the server could not be reached." });
4618
+ throw error;
4619
+ }
4620
+ if (!response.ok) {
4621
+ const error = await refusal2(response, operation, "Your message was not sent.");
4622
+ store.settleChat(localId, { failed: error.message });
4623
+ throw error;
4624
+ }
4625
+ const saved = chatFromRow(await response.json());
4626
+ if (saved === null) {
4627
+ store.settleChat(localId, { failed: "The server's answer could not be read." });
4628
+ return;
4629
+ }
4630
+ store.settleChat(localId, saved);
4631
+ },
4632
+ async loadChat() {
4633
+ const operation = "meet.loadChat";
4634
+ const id = meetingId(operation);
4635
+ const hdrs = headers();
4636
+ const response = await api.request({
4637
+ path: `${MEET_ROUTES.chat}?meeting_id=${encodeURIComponent(id)}`,
4638
+ method: "GET",
4639
+ operation,
4640
+ ...hdrs !== void 0 ? { headers: hdrs } : {}
4641
+ });
4642
+ if (!response.ok) {
4643
+ throw await refusal2(
4644
+ response,
4645
+ operation,
4646
+ "Messages sent before you joined could not be read. New messages still arrive."
4647
+ );
4648
+ }
4649
+ const payload = await response.json();
4650
+ const rows = Array.isArray(payload.messages) ? payload.messages : [];
4651
+ const messages = rows.map(
4652
+ (row) => typeof row === "object" && row !== null ? chatFromRow(row) : null
4653
+ ).filter((message) => message !== null);
4654
+ store.loadChat(messages);
4655
+ }
4656
+ };
4657
+ }
4658
+
4151
4659
  // src/core/record.ts
4152
4660
  var import_format = require("@ai-matrx/kit/format");
4153
4661
  function activeWrapUpSummary(notes) {