@tribe-nest/media-client 0.1.0 → 0.1.1

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.
@@ -43,7 +43,12 @@ describe("the handshake", () => {
43
43
 
44
44
  const first = server.received[0];
45
45
  expect(first?.method).toBe("join");
46
- expect(first).toMatchObject({ method: "join", id: 0, token: "join-ticket", protocolVersion: MEDIA_PROTOCOL_VERSION });
46
+ expect(first).toMatchObject({
47
+ method: "join",
48
+ id: 0,
49
+ token: "join-ticket",
50
+ protocolVersion: MEDIA_PROTOCOL_VERSION,
51
+ });
47
52
  expect(server.invalidFrames).toEqual([]);
48
53
  });
49
54
 
@@ -78,9 +83,12 @@ describe("the handshake", () => {
78
83
  // The reply says the token was accepted; the EVENT carries the router
79
84
  // capabilities, the peer snapshot and the recording flag. Resolving early
80
85
  // hands the caller a room it knows nothing about.
81
- const { server, signal } = harness({ autoJoin: false, onRequest: (frame, socket, srv) => {
82
- if (frame.method === "join") srv.reply(frame.id, { accepted: true }, socket);
83
- } });
86
+ const { server, signal } = harness({
87
+ autoJoin: false,
88
+ onRequest: (frame, socket, srv) => {
89
+ if (frame.method === "join") srv.reply(frame.id, { accepted: true }, socket);
90
+ },
91
+ });
84
92
 
85
93
  let settled = false;
86
94
  const pending = signal.connect().then(() => (settled = true));
@@ -93,9 +101,12 @@ describe("the handshake", () => {
93
101
  });
94
102
 
95
103
  it("rejects with the refusal code and closes the socket", async () => {
96
- const { server, signal, closes } = harness({ autoJoin: false, onRequest: (frame, socket, srv) => {
97
- if (frame.method === "join") srv.fail(frame.id, "unauthorized", "no", socket);
98
- } });
104
+ const { server, signal, closes } = harness({
105
+ autoJoin: false,
106
+ onRequest: (frame, socket, srv) => {
107
+ if (frame.method === "join") srv.fail(frame.id, "unauthorized", "no", socket);
108
+ },
109
+ });
99
110
 
100
111
  await expect(signal.connect()).rejects.toMatchObject({ name: "MediaError", code: "unauthorized" });
101
112
  expect(signal.phase).toBe("closed");
@@ -148,6 +159,62 @@ describe("the handshake", () => {
148
159
  expect(signal.phase).toBe("closed");
149
160
  });
150
161
 
162
+ /**
163
+ * A credential fetch that FAILS.
164
+ *
165
+ * The other side of the same window: no socket exists yet, so no close event
166
+ * is ever going to arrive to end the attempt. Left alone, the phase stayed
167
+ * `connecting`, no close handler fired, and every later `connect()` was
168
+ * refused as "already connecting", which in a UI was a spinner for the rest
169
+ * of the session after one 409 from the ticket endpoint.
170
+ */
171
+ it("ends the attempt when the credential fetch rejects, reports the cause, and permits another connect", async () => {
172
+ const server = new FakeSignalServer();
173
+ const closes: DisconnectCause[] = [];
174
+ let calls = 0;
175
+ const signal = new MediaSignal({
176
+ getCredentials: async () => {
177
+ calls += 1;
178
+ if (calls === 1) throw new Error("HTTP 409: window not open");
179
+ return { mediaUrl: MEDIA_URL, token: "join-ticket" };
180
+ },
181
+ webSocket: server.factory,
182
+ });
183
+ signal.onClose((cause) => closes.push(cause));
184
+
185
+ await expect(signal.connect()).rejects.toThrow(/HTTP 409/);
186
+
187
+ // Ended the way a failed handshake is: phase reset, cause reported, and no
188
+ // socket was ever opened for it.
189
+ expect(signal.phase).toBe("closed");
190
+ expect(closes).toEqual([{ type: "socket_closed", reason: "HTTP 409: window not open" }]);
191
+ expect(server.sockets).toEqual([]);
192
+
193
+ // And the next attempt is not refused as "already connecting".
194
+ await expect(signal.connect()).resolves.toMatchObject({ event: "joined" });
195
+ expect(signal.phase).toBe("joined");
196
+ expect(server.sockets).toHaveLength(1);
197
+ signal.close();
198
+ });
199
+
200
+ it("reports a MediaError thrown by the credential fetch as a refusal", async () => {
201
+ const server = new FakeSignalServer();
202
+ const closes: DisconnectCause[] = [];
203
+ const signal = new MediaSignal({
204
+ getCredentials: async () => {
205
+ throw new MediaError("forbidden", "sealed");
206
+ },
207
+ webSocket: server.factory,
208
+ });
209
+ signal.onClose((cause) => closes.push(cause));
210
+
211
+ await expect(signal.connect()).rejects.toMatchObject({ name: "MediaError", code: "forbidden" });
212
+ // Carried as `refused` so the reconnect policy can decide on the CODE, the
213
+ // same way it does for a refusal from the node itself.
214
+ expect(closes).toEqual([{ type: "refused", code: "forbidden", message: "sealed" }]);
215
+ expect(signal.phase).toBe("closed");
216
+ });
217
+
151
218
  it("opens one socket, not two, when a second connect starts inside the first credential fetch", async () => {
152
219
  const server = new FakeSignalServer();
153
220
  const gates: (() => void)[] = [];
@@ -186,9 +253,11 @@ describe("the handshake", () => {
186
253
  describe("request and reply correlation", () => {
187
254
  it("routes each reply to its own request, whatever order they arrive in", async () => {
188
255
  const ids: number[] = [];
189
- const { server, signal } = harness({ onRequest: (frame) => {
190
- if (frame.method !== "join") ids.push(frame.id);
191
- } });
256
+ const { server, signal } = harness({
257
+ onRequest: (frame) => {
258
+ if (frame.method !== "join") ids.push(frame.id);
259
+ },
260
+ });
192
261
  await signal.connect();
193
262
 
194
263
  const first = signal.request({ method: "pauseProducer", producerId: "p-1" });
@@ -206,9 +275,11 @@ describe("request and reply correlation", () => {
206
275
  });
207
276
 
208
277
  it("turns an ok:false reply into a MediaError carrying the code", async () => {
209
- const { signal } = harness({ onRequest: (frame, socket, srv) => {
210
- if (frame.method === "consume") srv.fail(frame.id, "not_subscribable", undefined, socket);
211
- } });
278
+ const { signal } = harness({
279
+ onRequest: (frame, socket, srv) => {
280
+ if (frame.method === "consume") srv.fail(frame.id, "not_subscribable", undefined, socket);
281
+ },
282
+ });
212
283
  await signal.connect();
213
284
 
214
285
  const error = await signal
@@ -222,12 +293,14 @@ describe("request and reply correlation", () => {
222
293
  it("ignores a second reply to an id it has already settled", async () => {
223
294
  // "exactly one reply with that id" is the protocol. A node that breaks it
224
295
  // must not corrupt the client, and must not do it quietly either.
225
- const { signal, logs } = harness({ onRequest: (frame, socket, srv) => {
226
- if (frame.method === "closeProducer") {
227
- srv.reply(frame.id, { closed: true }, socket);
228
- srv.reply(frame.id, { closed: "again" }, socket);
229
- }
230
- } });
296
+ const { signal, logs } = harness({
297
+ onRequest: (frame, socket, srv) => {
298
+ if (frame.method === "closeProducer") {
299
+ srv.reply(frame.id, { closed: true }, socket);
300
+ srv.reply(frame.id, { closed: "again" }, socket);
301
+ }
302
+ },
303
+ });
231
304
  await signal.connect();
232
305
 
233
306
  expect(await signal.request({ method: "closeProducer", producerId: "p-1" })).toEqual({ closed: true });
@@ -308,6 +381,24 @@ describe("why the connection ended", () => {
308
381
  expect(closes).toEqual([{ type: "draining", reconnectAfterMs: 5_000 }]);
309
382
  });
310
383
 
384
+ it("reports a client close over a drain the node had announced", async () => {
385
+ const { server, signal, closes } = harness({
386
+ onRequest: (frame, socket, srv) => {
387
+ if (frame.method === "leave") srv.reply(frame.id, {}, socket);
388
+ },
389
+ });
390
+ await signal.connect();
391
+
392
+ // The node asks to be left, and the person leaves before it hangs up.
393
+ server.event({ event: "draining", reconnectAfterMs: 5_000 });
394
+ await flush();
395
+ await signal.leave();
396
+
397
+ // Reported as the drain, this became a reconnect the room then declined,
398
+ // and the screen offered a way back into a call somebody had just ended.
399
+ expect(closes).toEqual([{ type: "closed_by_client" }]);
400
+ });
401
+
311
402
  it("reports a closed room rather than a dropped socket", async () => {
312
403
  const { server, signal, closes } = harness();
313
404
  await signal.connect();
@@ -334,9 +425,11 @@ describe("why the connection ended", () => {
334
425
  });
335
426
 
336
427
  it("reports a client-initiated close, and leave() sends the frame first", async () => {
337
- const { server, signal, closes } = harness({ onRequest: (frame, socket, srv) => {
338
- if (frame.method === "leave") srv.reply(frame.id, {}, socket);
339
- } });
428
+ const { server, signal, closes } = harness({
429
+ onRequest: (frame, socket, srv) => {
430
+ if (frame.method === "leave") srv.reply(frame.id, {}, socket);
431
+ },
432
+ });
340
433
  await signal.connect();
341
434
 
342
435
  await signal.leave();
@@ -46,6 +46,18 @@ describe("joining", () => {
46
46
  expect(rejoined.phase).toBe("joined");
47
47
  });
48
48
 
49
+ it("keeps an active set that arrived AHEAD of the snapshot, pruned to what the snapshot names", () => {
50
+ // The contract fixes no order between `joined` and the node's first
51
+ // `activeSpeakers`. Resetting the set on `joined` discarded an instruction
52
+ // the node had already given, and the client consumed nothing until the
53
+ // next speaker change.
54
+ const early = reduceRoomState(initialRoomState, { event: "activeSpeakers", producerIds: ["pa", "gone"] });
55
+ const state = reduceRoomState(early, joined());
56
+
57
+ expect(state.activeSpeakers).toEqual(["pa"]);
58
+ expect(activeProducers(state).map((p) => p.producerId)).toEqual(["pa"]);
59
+ });
60
+
49
61
  it("clears a drain flag from the previous connection", () => {
50
62
  const draining = reduceRoomState(afterJoin(), { event: "draining", reconnectAfterMs: 3_000 });
51
63
  expect(draining.draining).toEqual({ reconnectAfterMs: 3_000 });
@@ -102,7 +114,12 @@ describe("peers and producers", () => {
102
114
  expect(state.activeSpeakers).toEqual(["pc", "pa"]);
103
115
  expect(activeProducers(state).map((p) => p.producerId)).toEqual(["pa"]);
104
116
 
105
- const arrived = reduceRoomState(state, { event: "producerAppeared", producerId: "pc", identity: "c", kind: "video" });
117
+ const arrived = reduceRoomState(state, {
118
+ event: "producerAppeared",
119
+ producerId: "pc",
120
+ identity: "c",
121
+ kind: "video",
122
+ });
106
123
  expect(activeProducers(arrived).map((p) => p.producerId)).toEqual(["pc", "pa"]);
107
124
  });
108
125
 
@@ -134,7 +151,10 @@ describe("the information barrier", () => {
134
151
 
135
152
  it("keeps only the named identities under an allow rule, and nothing under none", () => {
136
153
  const state = afterJoin();
137
- const allowed = reduceRoomState(state, { event: "subscribeRuleChanged", subscribe: { mode: "allow", identities: ["b"] } });
154
+ const allowed = reduceRoomState(state, {
155
+ event: "subscribeRuleChanged",
156
+ subscribe: { mode: "allow", identities: ["b"] },
157
+ });
138
158
  expect(visibleProducers(allowed).map((p) => p.identity)).toEqual(["b"]);
139
159
 
140
160
  const none = reduceRoomState(state, { event: "subscribeRuleChanged", subscribe: { mode: "none" } });
@@ -196,8 +196,14 @@ function delayFor(cause: DisconnectCause, attempt: number, o: ReconnectOptions):
196
196
  *
197
197
  * Deliberately knows nothing about sockets: `attempt` returns whatever a
198
198
  * session is and `waitForClose` resolves with why it ended. That is what makes
199
- * the whole loop testable with no server, and it is the same loop the room API
200
- * (P3b) should use rather than deriving a second one.
199
+ * the whole loop testable with no server.
200
+ *
201
+ * Who uses it: headless callers with nothing else to do between attempts (the
202
+ * load harness, an egress leg, the SIP gateway). `room/room.ts` does NOT: the
203
+ * room needs a retry it can cancel from `close()`, and has to publish whether
204
+ * one is booked (`isRecovering`) between attempts, so it holds its own timer
205
+ * and calls `decideReconnect` directly. Both drivers share the ONE policy in
206
+ * `decideReconnect`; neither restates it.
201
207
  */
202
208
  export async function superviseConnection<TSession>(input: {
203
209
  attempt: () => Promise<TSession>;
@@ -119,7 +119,9 @@ export function assertTokenNotInUrl(mediaUrl: string): void {
119
119
  }
120
120
  for (const key of url.searchParams.keys()) {
121
121
  if (/token|jwt|ticket|auth/i.test(key)) {
122
- throw new Error(`refusing to connect: mediaUrl carries "${key}" in the query string. The token goes in the first frame.`);
122
+ throw new Error(
123
+ `refusing to connect: mediaUrl carries "${key}" in the query string. The token goes in the first frame.`,
124
+ );
123
125
  }
124
126
  }
125
127
  }
@@ -189,13 +191,32 @@ export class MediaSignal {
189
191
  const attempt = ++this.attempt;
190
192
  this.phaseValue = "connecting";
191
193
 
192
- const credentials = await this.options.getCredentials();
193
- // Fetching a ticket is a network round trip of its own, so this window is
194
- // seconds wide and a caller giving up inside it is ordinary. Every later
195
- // step has a socket and is cancelled by `handleClose`; this one is the only
196
- // part of a connect that a `close()` could not reach.
197
- this.assertAttemptIsCurrent(attempt);
198
- assertTokenNotInUrl(credentials.mediaUrl);
194
+ let credentials: MediaCoreCredentials;
195
+ try {
196
+ credentials = await this.options.getCredentials();
197
+ // Fetching a ticket is a network round trip of its own, so this window is
198
+ // seconds wide and a caller giving up inside it is ordinary. Every later
199
+ // step has a socket and is cancelled by `handleClose`; this one is the
200
+ // only part of a connect that a `close()` could not reach.
201
+ this.assertAttemptIsCurrent(attempt);
202
+ assertTokenNotInUrl(credentials.mediaUrl);
203
+ } catch (error) {
204
+ // No socket exists yet, so nothing is going to arrive and end this attempt
205
+ // for us: without this the phase stays `connecting` for ever, no close
206
+ // handler ever fires, and every later `connect()` is refused as "already
207
+ // connecting". A ticket endpoint answering 409 at the edge of the booking
208
+ // window was a spinner for the rest of the session. So a failed fetch is
209
+ // ended the way a failed handshake is: cause recorded, close handlers
210
+ // told, phase reset. Only when this attempt is still the live one,
211
+ // though. An attempt that `close()` or a second `connect()` overtook has
212
+ // already been ended by whichever did it, and must not close THEIR
213
+ // attempt on the way out.
214
+ if (attempt === this.attempt && this.phaseValue === "connecting") {
215
+ if (!this.terminalCause) this.terminalCause = causeFromError(error);
216
+ this.closeSocket();
217
+ }
218
+ throw error;
219
+ }
199
220
 
200
221
  const factory = this.options.webSocket ?? defaultWebSocketFactory;
201
222
  const socket = factory(credentials.mediaUrl);
@@ -229,10 +250,16 @@ export class MediaSignal {
229
250
  request(frame: SignalRequest, timeoutMs?: number): Promise<unknown> {
230
251
  const socket = this.socket;
231
252
  if (!socket || this.phaseValue === "closed" || this.phaseValue === "idle") {
232
- return Promise.reject(new MediaError("internal", `cannot send ${frame.method}: the signal connection is ${this.phaseValue}`));
253
+ return Promise.reject(
254
+ new MediaError("internal", `cannot send ${frame.method}: the signal connection is ${this.phaseValue}`),
255
+ );
233
256
  }
234
257
  const id = this.nextId++;
235
- const promise = this.track(id, frame.method, timeoutMs ?? this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
258
+ const promise = this.track(
259
+ id,
260
+ frame.method,
261
+ timeoutMs ?? this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
262
+ );
236
263
  try {
237
264
  this.send({ ...frame, id } as RequestFrame);
238
265
  } catch (error) {
@@ -260,9 +287,18 @@ export class MediaSignal {
260
287
  this.close();
261
288
  }
262
289
 
263
- /** Closes the socket. Idempotent. */
290
+ /**
291
+ * Closes the socket. Idempotent.
292
+ *
293
+ * A close the CLIENT asked for is the cause, whatever the node had announced
294
+ * before it. `draining` is recorded the moment the frame arrives, seconds
295
+ * before the node actually hangs up, and a person who presses Leave inside
296
+ * that window has left: reporting the drain instead sent the room to
297
+ * "reconnecting" over a call somebody had deliberately ended, and the screen
298
+ * offered them a way back into it.
299
+ */
264
300
  close(): void {
265
- if (!this.terminalCause) this.terminalCause = { type: "closed_by_client" };
301
+ this.terminalCause = { type: "closed_by_client" };
266
302
  this.closeSocket();
267
303
  }
268
304
 
package/src/core/state.ts CHANGED
@@ -1,4 +1,10 @@
1
- import { maySubscribe, type EventFrame, type MediaGrants, type Peer, type SubscribeRule } from "@tribe-nest/media-protocol";
1
+ import {
2
+ maySubscribe,
3
+ type EventFrame,
4
+ type MediaGrants,
5
+ type Peer,
6
+ type SubscribeRule,
7
+ } from "@tribe-nest/media-protocol";
2
8
 
3
9
  /**
4
10
  * The room as the event frames describe it, as a pure reducer.
@@ -65,22 +71,33 @@ export const initialRoomState: RoomState = {
65
71
  */
66
72
  export function reduceRoomState(state: RoomState, frame: EventFrame): RoomState {
67
73
  switch (frame.event) {
68
- case "joined":
74
+ case "joined": {
69
75
  // A wholesale replace, not a merge. `joined` also arrives after a
70
76
  // reconnect, and merging would leave peers who left while we were away
71
77
  // on screen for the rest of the call.
78
+ //
79
+ // The active set is the ONE field carried across, pruned to the producers
80
+ // this snapshot names. The contract fixes no order between `joined` and
81
+ // the node's first `activeSpeakers` broadcast, so a set that lands first
82
+ // is the node's current instruction, not a stale one, and resetting it
83
+ // here left a client that had been told what to consume consuming
84
+ // nothing until the next speaker change. A set left over from a PREVIOUS
85
+ // connection is not this reducer's to recognise (a rejoin and a first
86
+ // join look the same from here); the room clears it on disconnect.
87
+ const producers = frame.producers.map((p) => ({ ...p, paused: false }));
72
88
  return {
73
89
  phase: "joined",
74
90
  identity: frame.identity,
75
91
  room: frame.room,
76
92
  peers: frame.peers,
77
- producers: frame.producers.map((p) => ({ ...p, paused: false })),
78
- activeSpeakers: [],
93
+ producers,
94
+ activeSpeakers: pruneSpeakers(state.activeSpeakers, producers),
79
95
  recording: frame.recording,
80
96
  subscribeRule: null,
81
97
  draining: null,
82
98
  closedReason: null,
83
99
  };
100
+ }
84
101
 
85
102
  case "peerJoined": {
86
103
  const without = state.peers.filter((p) => p.identity !== frame.peer.identity);
@@ -89,10 +89,18 @@ function nodeWith(producers: { producerId: string; identity: string; kind: "audi
89
89
  onRequest: (frame, socket) => {
90
90
  if (frame.method === "join") return;
91
91
  if (frame.method === "createTransport") {
92
- return server.reply(frame.id, { transportId: "t-1", iceParameters: {}, iceCandidates: [], dtlsParameters: {} }, socket);
92
+ return server.reply(
93
+ frame.id,
94
+ { transportId: "t-1", iceParameters: {}, iceCandidates: [], dtlsParameters: {} },
95
+ socket,
96
+ );
93
97
  }
94
98
  if (frame.method === "consume") {
95
- return server.reply(frame.id, { consumerId: "c-1", producerId: frame.producerId, kind: "audio", rtpParameters: {} }, socket);
99
+ return server.reply(
100
+ frame.id,
101
+ { consumerId: "c-1", producerId: frame.producerId, kind: "audio", rtpParameters: {} },
102
+ socket,
103
+ );
96
104
  }
97
105
  return server.reply(frame.id, {}, socket);
98
106
  },
@@ -409,7 +417,14 @@ describe("one press, one publish", () => {
409
417
  ...fakeTransport(),
410
418
  async produce(input: { appData?: Record<string, unknown> }) {
411
419
  produced.push(String(input.appData?.source));
412
- return { id: `p-${produced.length}`, kind: "audio" as const, closed: false, pause: vi.fn(), resume: vi.fn(), close: vi.fn() };
420
+ return {
421
+ id: `p-${produced.length}`,
422
+ kind: "audio" as const,
423
+ closed: false,
424
+ pause: vi.fn(),
425
+ resume: vi.fn(),
426
+ close: vi.fn(),
427
+ };
413
428
  },
414
429
  }),
415
430
  });
@@ -463,6 +478,79 @@ describe("one press, one publish", () => {
463
478
  });
464
479
  });
465
480
 
481
+ /**
482
+ * A mute is a pause, and the hook says so.
483
+ *
484
+ * `setPaused` is what a Mute or a camera-off control calls, in place of the
485
+ * unpublish-then-publish a toggle used to do. The difference the person sees is
486
+ * on Safari, which puts a permission prompt in front of EVERY `getUserMedia`,
487
+ * so a toggle that re-captured asked permission on every unmute. What is pinned
488
+ * here is that the hook reflects it: `paused` follows the room, the capture is
489
+ * never re-asked for, and `isMicrophoneEnabled` still means "published".
490
+ */
491
+ describe("useLocalMedia pauses in place of republishing", () => {
492
+ afterEach(() => {
493
+ delete (navigator as { mediaDevices?: unknown }).mediaDevices;
494
+ });
495
+
496
+ it("mutes and unmutes without a second capture, and reports it", async () => {
497
+ const track = { kind: "audio", stop: vi.fn(), enabled: true } as unknown as MediaStreamTrack;
498
+ const getUserMedia = vi.fn(async () => ({ getTracks: () => [track] }) as unknown as MediaStream);
499
+ Object.defineProperty(navigator, "mediaDevices", {
500
+ configurable: true,
501
+ value: { getUserMedia, getDisplayMedia: getUserMedia },
502
+ });
503
+ const server = nodeWith();
504
+
505
+ let controls: ReturnType<typeof useLocalMedia> | undefined;
506
+ function Probe() {
507
+ controls = useLocalMedia();
508
+ return <div data-testid="muted">{String(controls.paused.microphone)}</div>;
509
+ }
510
+ const { getByTestId } = render(wrap(server, <Probe />));
511
+ await waitFor(() => expect(controls).toBeDefined());
512
+
513
+ await act(async () => {
514
+ await controls?.publishMicrophone();
515
+ });
516
+ expect(getByTestId("muted").textContent).toBe("false");
517
+
518
+ await act(async () => {
519
+ await controls?.setPaused("microphone", true);
520
+ });
521
+ await waitFor(() => expect(getByTestId("muted").textContent).toBe("true"));
522
+ // Still published, so a control can draw "muted" rather than "off".
523
+ expect(controls?.isMicrophoneEnabled).toBe(true);
524
+ expect(server.received.filter((f) => f.method === "pauseProducer")).toHaveLength(1);
525
+
526
+ await act(async () => {
527
+ await controls?.setPaused("microphone", false);
528
+ });
529
+ await waitFor(() => expect(getByTestId("muted").textContent).toBe("false"));
530
+ // The whole point: one capture, one permission prompt, for the whole call.
531
+ expect(getUserMedia).toHaveBeenCalledTimes(1);
532
+ expect(server.received.filter((f) => f.method === "closeProducer")).toHaveLength(0);
533
+ });
534
+
535
+ it("does nothing for a source that is not published", async () => {
536
+ const server = nodeWith();
537
+ let controls: ReturnType<typeof useLocalMedia> | undefined;
538
+ function Probe() {
539
+ controls = useLocalMedia();
540
+ return null;
541
+ }
542
+ render(wrap(server, <Probe />));
543
+ await waitFor(() => expect(controls).toBeDefined());
544
+
545
+ await act(async () => {
546
+ await controls?.setPaused("camera", true);
547
+ });
548
+
549
+ expect(server.received.filter((f) => f.method === "pauseProducer")).toHaveLength(0);
550
+ expect(controls?.paused.camera).toBe(false);
551
+ });
552
+ });
553
+
466
554
  /**
467
555
  * The subscribe barrier, applied where it is rendered.
468
556
  *
@@ -17,6 +17,7 @@ import {
17
17
  MediaRoom,
18
18
  type ConnectionState,
19
19
  type LocalPublication,
20
+ type LocalPublicationSource,
20
21
  type MediaRoomOptions,
21
22
  type MediaTrack,
22
23
  } from "../room/room";
@@ -140,10 +141,7 @@ function useRoomSnapshot<T>(select: (room: MediaRoom) => T, fallback: T): T {
140
141
  // module-level selectors below.
141
142
  const { room } = useRoomContext();
142
143
 
143
- const subscribe = useCallback(
144
- (onChange: () => void) => (room ? room.onChange(onChange) : () => undefined),
145
- [room],
146
- );
144
+ const subscribe = useCallback((onChange: () => void) => (room ? room.onChange(onChange) : () => undefined), [room]);
147
145
 
148
146
  // The selector must return a STABLE reference for unchanged state, or
149
147
  // `useSyncExternalStore` re-renders forever. `RoomState` is produced by a
@@ -274,16 +272,37 @@ export function useRemoteTrack(producerId: string): {
274
272
  return { track, attach };
275
273
  }
276
274
 
277
- export type LocalSource = "camera" | "microphone" | "screen";
275
+ export type LocalSource = LocalPublicationSource;
278
276
 
279
277
  export type LocalMediaControls = {
280
278
  publishCamera: () => Promise<void>;
281
279
  publishMicrophone: () => Promise<void>;
282
280
  publishScreen: () => Promise<void>;
283
281
  unpublish: (source: LocalSource) => Promise<void>;
282
+ /**
283
+ * Mute or unmute a source that is already published, keeping its capture.
284
+ *
285
+ * What a Mute button and a camera-off button call. NOT `unpublish` and a
286
+ * later re-publish: that path starts with `getUserMedia` again, and Safari
287
+ * puts a permission prompt in front of every call to it, so a toggle built
288
+ * that way asked permission on every unmute. No-op for a source that is not
289
+ * published.
290
+ */
291
+ setPaused: (source: LocalSource, paused: boolean) => Promise<void>;
292
+ /** Published, whether or not currently paused. */
284
293
  isCameraEnabled: boolean;
285
294
  isMicrophoneEnabled: boolean;
286
295
  screenSharing: boolean;
296
+ /** Published AND paused at the source: muted, camera off. */
297
+ paused: Record<LocalSource, boolean>;
298
+ /**
299
+ * What the person was sending when the connection dropped, and is not now.
300
+ *
301
+ * A drop stops every local capture (the camera light goes out, on purpose)
302
+ * and the SDK does not turn it back on by itself. This is what a screen tells
303
+ * the person so they press the button. Cleared per source on republish.
304
+ */
305
+ lostSources: readonly LocalSource[];
287
306
  /**
288
307
  * A capture or a publish is in flight for this source.
289
308
  *
@@ -297,6 +316,8 @@ export type LocalMediaControls = {
297
316
  };
298
317
 
299
318
  const NOTHING_PENDING: Record<LocalSource, boolean> = { camera: false, microphone: false, screen: false };
319
+ const NOTHING_PAUSED: Record<LocalSource, boolean> = NOTHING_PENDING;
320
+ const selectLostSources = (room: MediaRoom): readonly LocalSource[] => room.lostPublicationSources;
300
321
 
301
322
  /**
302
323
  * Capture and publish.
@@ -310,8 +331,19 @@ export function useLocalMedia(): LocalMediaControls {
310
331
  const [error, setError] = useState<Error | undefined>(undefined);
311
332
  const [pending, setPending] = useState<Record<LocalSource, boolean>>(NOTHING_PENDING);
312
333
  const publications = useRoomSnapshot(selectPublications, EMPTY_PUBLICATIONS);
334
+ const lostSources = useRoomSnapshot(selectLostSources, EMPTY_SOURCES);
313
335
 
314
336
  const has = (source: string) => publications.some((p) => p.source === source);
337
+ // Rebuilt only when the publications snapshot moves, which is the only time
338
+ // a pause flag can have changed.
339
+ const paused = useMemo<Record<LocalSource, boolean>>(() => {
340
+ const isPaused = (source: LocalSource) => {
341
+ const mine = publications.filter((p) => p.source === source);
342
+ return mine.length > 0 && mine.every((p) => p.paused);
343
+ };
344
+ const next = { camera: isPaused("camera"), microphone: isPaused("microphone"), screen: isPaused("screen") };
345
+ return next.camera || next.microphone || next.screen ? next : NOTHING_PAUSED;
346
+ }, [publications]);
315
347
 
316
348
  /**
317
349
  * One operation per source at a time, held in a REF.
@@ -372,9 +404,7 @@ export function useLocalMedia(): LocalMediaControls {
372
404
  const stream =
373
405
  source === "screen"
374
406
  ? await navigator.mediaDevices.getDisplayMedia({ video: true })
375
- : await navigator.mediaDevices.getUserMedia(
376
- source === "camera" ? { video: true } : { audio: true },
377
- );
407
+ : await navigator.mediaDevices.getUserMedia(source === "camera" ? { video: true } : { audio: true });
378
408
  captured = stream.getTracks();
379
409
  const track = captured[0];
380
410
  if (!track) throw new Error(`no ${source} track was captured`);
@@ -408,14 +438,30 @@ export function useLocalMedia(): LocalMediaControls {
408
438
  [room, exclusively],
409
439
  );
410
440
 
441
+ // Under the same lock, for the same reason: a mute pressed while the publish
442
+ // it is muting is still capturing would find nothing to pause.
443
+ const setPaused = useCallback(
444
+ async (source: LocalSource, value: boolean) =>
445
+ exclusively(source, async () => {
446
+ if (!room) return;
447
+ for (const publication of room.localPublications.filter((p) => p.source === source)) {
448
+ await room.setPaused(publication.producerId, value);
449
+ }
450
+ }),
451
+ [room, exclusively],
452
+ );
453
+
411
454
  return {
412
455
  publishCamera: useCallback(() => publish("camera"), [publish]),
413
456
  publishMicrophone: useCallback(() => publish("microphone"), [publish]),
414
457
  publishScreen: useCallback(() => publish("screen"), [publish]),
415
458
  unpublish,
459
+ setPaused,
416
460
  isCameraEnabled: has("camera"),
417
461
  isMicrophoneEnabled: has("microphone"),
418
462
  screenSharing: has("screen"),
463
+ paused,
464
+ lostSources,
419
465
  pending,
420
466
  error,
421
467
  };
@@ -425,6 +471,7 @@ export function useLocalMedia(): LocalMediaControls {
425
471
  * `useSyncExternalStore` a new array on every read and loop it. */
426
472
  const EMPTY_TRACKS: readonly MediaTrack[] = [];
427
473
  const EMPTY_PUBLICATIONS: MediaRoom["localPublications"] = [];
474
+ const EMPTY_SOURCES: readonly LocalSource[] = [];
428
475
  const EMPTY_STATE: RoomState = {
429
476
  phase: "idle",
430
477
  identity: null,