@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.
- package/README.md +20 -4
- package/build/core/reconnect.d.ts +8 -2
- package/build/core/reconnect.d.ts.map +1 -1
- package/build/core/reconnect.js +8 -2
- package/build/core/reconnect.js.map +1 -1
- package/build/core/signal.d.ts +10 -1
- package/build/core/signal.d.ts.map +1 -1
- package/build/core/signal.js +39 -10
- package/build/core/signal.js.map +1 -1
- package/build/core/state.d.ts.map +1 -1
- package/build/core/state.js +14 -3
- package/build/core/state.js.map +1 -1
- package/build/react/index.d.ts +23 -2
- package/build/react/index.d.ts.map +1 -1
- package/build/react/index.js +26 -0
- package/build/react/index.js.map +1 -1
- package/build/room/room.d.ts +68 -5
- package/build/room/room.d.ts.map +1 -1
- package/build/room/room.js +213 -56
- package/build/room/room.js.map +1 -1
- package/package.json +3 -1
- package/src/core/_tests/signal.spec.ts +115 -22
- package/src/core/_tests/state.spec.ts +22 -2
- package/src/core/reconnect.ts +8 -2
- package/src/core/signal.ts +48 -12
- package/src/core/state.ts +21 -4
- package/src/react/_tests/hooks.spec.tsx +91 -3
- package/src/react/index.tsx +55 -8
- package/src/room/_tests/room.spec.ts +510 -8
- package/src/room/room.ts +236 -60
|
@@ -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({
|
|
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({
|
|
82
|
-
|
|
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({
|
|
97
|
-
|
|
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({
|
|
190
|
-
|
|
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({
|
|
210
|
-
|
|
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({
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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({
|
|
338
|
-
|
|
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, {
|
|
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, {
|
|
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" } });
|
package/src/core/reconnect.ts
CHANGED
|
@@ -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
|
|
200
|
-
*
|
|
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>;
|
package/src/core/signal.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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(
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
*
|
package/src/react/index.tsx
CHANGED
|
@@ -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 =
|
|
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,
|