Skip to content

Commit fcacc02

Browse files
fix(desktop): complete P2P relay and media lifecycle parity (#111)
1 parent 110d3d5 commit fcacc02

8 files changed

Lines changed: 903 additions & 231 deletions

File tree

‎apps/desktop/src/renderer/components/video/VideoViewer.test.tsx‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,26 @@ describe('VideoViewer', () => {
5454
expect(screen.getByText('The stream will appear here when ready')).toBeInTheDocument();
5555
});
5656

57+
it('adjusts gain without recreating playback or stopping the input track', () => {
58+
const dispose = vi.fn();
59+
const setGain = vi.fn();
60+
const mix = vi.spyOn(remoteAudioGain, 'amplifyRemoteAudio').mockReturnValue({
61+
stream: new MediaStream(),
62+
dispose,
63+
setGain,
64+
});
65+
const stream = createMockStream(['audio']);
66+
const { rerender, unmount } = render(
67+
<VideoViewer stream={stream} connectionState="connected" speakerGain={1} />
68+
);
69+
rerender(<VideoViewer stream={stream} connectionState="connected" speakerGain={2} />);
70+
expect(mix).toHaveBeenCalledOnce();
71+
expect(dispose).not.toHaveBeenCalled();
72+
expect(setGain).toHaveBeenLastCalledWith(2);
73+
unmount();
74+
mix.mockRestore();
75+
});
76+
5777
it('renders connecting spinner when connecting', () => {
5878
render(<VideoViewer stream={null} connectionState="connecting" />);
5979

‎apps/desktop/src/renderer/components/video/VideoViewer.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export function VideoViewer({
7272
// through a gain stage and swapped back into the stream before playback.
7373
// Video tracks pass through untouched.
7474
const amplifiedRef = useRef<AmplifiedAudioTrack | null>(null);
75+
const speakerGainRef = useRef(speakerGain);
76+
speakerGainRef.current = speakerGain;
7577
const [playbackStream, setPlaybackStream] = useState<MediaStream | null>(null);
7678
const remoteAudioTrackIds =
7779
stream
@@ -92,7 +94,7 @@ export function VideoViewer({
9294
return;
9395
}
9496

95-
const amplified = amplifyRemoteAudio(audioTracks, speakerGain);
97+
const amplified = amplifyRemoteAudio(audioTracks, speakerGainRef.current);
9698
amplifiedRef.current = amplified;
9799

98100
const composed = new MediaStream();
@@ -110,7 +112,7 @@ export function VideoViewer({
110112
};
111113
// Rebuild when the underlying audio track is replaced, which renegotiation
112114
// can do without changing the stream's identity.
113-
}, [stream, remoteAudioTrackIds, speakerGain]);
115+
}, [stream, remoteAudioTrackIds]);
114116

115117
// Adjust an existing graph in place rather than rebuilding it on every nudge
116118
// of a volume slider.

‎apps/desktop/src/renderer/hooks/useWebRTCHostAPI.group.test.ts‎

Lines changed: 237 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ vi.mock('../../shared/config', () => ({ API_BASE_URL: 'http://localhost:3000' })
66
vi.mock('@/lib/ipc', () => ({
77
getElectronAPI: () => ({ invoke: vi.fn().mockResolvedValue({ token: 'local-test-token' }) }),
88
}));
9-
vi.mock('@/lib/remoteAudioGain', () => ({
10-
amplifyRemoteAudio: (track: MediaStreamTrack) => ({
9+
const playback = vi.hoisted(() => ({ amplify: vi.fn() }));
10+
vi.mock('@/lib/remoteAudioGain', () => ({ amplifyRemoteAudio: playback.amplify }));
11+
function amplify(track: MediaStreamTrack) {
12+
return {
1113
stream: new MediaStream([track]),
1214
dispose: vi.fn(),
1315
setGain: vi.fn(),
14-
}),
15-
}));
16+
};
17+
}
1618

1719
class TestEventSource {
1820
static latest: TestEventSource;
@@ -157,7 +159,7 @@ async function setup() {
157159
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(new MediaStream([hostMic])) },
158160
});
159161
const localStream = new MediaStream([hostMic]);
160-
const { result, unmount } = renderHook(() =>
162+
const { result, unmount, rerender } = renderHook(() =>
161163
useWebRTCHostAPI({ sessionId: 'group', hostId: 'host', localStream })
162164
);
163165
await act(async () => {
@@ -166,10 +168,11 @@ async function setup() {
166168
await emit('connected', {});
167169
const peer = (id: string) =>
168170
result.current.viewers.get(id)!.peerConnection as unknown as TestPeer;
169-
return { result, peer, unmount, hostMic };
171+
return { result, peer, unmount, hostMic, rerender };
170172
}
171173

172174
beforeEach(() => {
175+
playback.amplify.mockReset().mockImplementation(amplify);
173176
signals.length = 0;
174177
mockFetch.mockClear();
175178
vi.stubGlobal('EventSource', TestEventSource);
@@ -201,6 +204,234 @@ afterEach(() => {
201204
});
202205

203206
describe('desktop P2P group negotiation', () => {
207+
it('owns one relay per source and removes it on leave before a same-ID rejoin', async () => {
208+
const { peer } = await setup();
209+
await join('source');
210+
await join('target');
211+
await answer('source');
212+
await answer('target');
213+
const first = track('first');
214+
await act(async () => {
215+
peer('source').ontrack?.({ track: first });
216+
await flush();
217+
});
218+
await answer('target');
219+
await act(async () => {
220+
peer('source').ontrack?.({ track: first });
221+
await flush();
222+
});
223+
expect(
224+
peer('target')
225+
.getSenders()
226+
.filter((s) => s.track === first)
227+
).toHaveLength(1);
228+
expect(playback.amplify).toHaveBeenCalledTimes(1);
229+
// A new receiver object may retain the previous SDP msid.
230+
const replacement = track('first');
231+
await act(async () => {
232+
peer('source').ontrack?.({ track: replacement });
233+
await flush();
234+
});
235+
expect(
236+
peer('target')
237+
.getSenders()
238+
.filter((s) => s.track === first)
239+
).toHaveLength(0);
240+
expect(playback.amplify).toHaveBeenCalledTimes(2);
241+
await emit('presence-leave', { presences: [{ user_id: 'source' }] });
242+
expect(
243+
peer('target')
244+
.getSenders()
245+
.filter((s) => s.track === replacement)
246+
).toHaveLength(0);
247+
await answer('target');
248+
await join('source');
249+
const rejoined = track('rejoined');
250+
await act(async () => {
251+
peer('source').ontrack?.({ track: rejoined });
252+
await flush();
253+
});
254+
expect(
255+
peer('target')
256+
.getSenders()
257+
.filter((s) => s.track?.kind === 'audio')
258+
).toHaveLength(2);
259+
});
260+
261+
it('keeps muted relays for late joiners and preserves mute until channel open and rejoin', async () => {
262+
const { result, peer } = await setup();
263+
await join('source');
264+
act(() => result.current.muteViewer('source', true));
265+
const mic = track('muted');
266+
await act(async () => {
267+
peer('source').ontrack?.({ track: mic });
268+
await flush();
269+
});
270+
expect(mic.enabled).toBe(false);
271+
await join('target');
272+
expect(
273+
peer('target')
274+
.getSenders()
275+
.some((s) => s.track === mic)
276+
).toBe(true);
277+
act(() => {
278+
peer('source').dataChannel.readyState = 'open';
279+
peer('source').dataChannel.onopen?.();
280+
});
281+
expect(peer('source').dataChannel.send).toHaveBeenCalledWith(
282+
expect.stringContaining('"muted":true')
283+
);
284+
act(() => result.current.muteViewer('source', false));
285+
expect(mic.enabled).toBe(true);
286+
act(() => result.current.muteViewer('source', true));
287+
await emit('presence-leave', { presences: [{ user_id: 'source' }] });
288+
await join('source');
289+
const next = track('next-muted');
290+
act(() => peer('source').ontrack?.({ track: next }));
291+
expect(next.enabled).toBe(false);
292+
});
293+
294+
it('disposes replaced host playback and relays even when amplification fails', async () => {
295+
const { peer } = await setup();
296+
await join('source');
297+
await join('target');
298+
await answer('target');
299+
await act(async () => {
300+
peer('source').ontrack?.({ track: track('old') });
301+
await flush();
302+
});
303+
const old = playback.amplify.mock.results[0].value as ReturnType<typeof amplify>;
304+
const next = track('next');
305+
playback.amplify.mockImplementationOnce(() => {
306+
throw new Error('audio unavailable');
307+
});
308+
await act(async () => {
309+
peer('source').ontrack?.({ track: next });
310+
await flush();
311+
});
312+
expect(old.dispose).toHaveBeenCalledOnce();
313+
expect(
314+
peer('target')
315+
.getSenders()
316+
.some((s) => s.track === next)
317+
).toBe(true);
318+
});
319+
320+
it('does not restore video after unpublish supersedes an awaited audio replacement', async () => {
321+
const { result, peer } = await setup();
322+
await join('target');
323+
await answer('target');
324+
const pending = deferred<undefined>();
325+
peer('target').senders[0].replaceTrack.mockImplementationOnce(
326+
async (replacement: MediaStreamTrack) => {
327+
await pending.promise;
328+
peer('target').senders[0].track = replacement;
329+
}
330+
);
331+
let publishing!: Promise<void>;
332+
let stopping!: Promise<void>;
333+
await act(async () => {
334+
publishing = result.current.publishStream(
335+
new MediaStream([track('screen', 'video'), track('other-audio')])
336+
);
337+
await flush();
338+
stopping = result.current.unpublishStream();
339+
pending.resolve(undefined);
340+
await Promise.all([publishing, stopping]);
341+
await flush();
342+
});
343+
expect(
344+
peer('target')
345+
.getSenders()
346+
.some((s) => s.track?.kind === 'video')
347+
).toBe(false);
348+
expect(
349+
peer('target')
350+
.getSenders()
351+
.some((s) => s.track?.id === 'host-mic')
352+
).toBe(true);
353+
});
354+
355+
it('skips a publication already superseded by its caller', async () => {
356+
const { result, peer } = await setup();
357+
await join('target');
358+
await answer('target');
359+
await act(async () => {
360+
await result.current.publishStream(new MediaStream([track('stale', 'video')]), () => true);
361+
});
362+
expect(
363+
peer('target')
364+
.getSenders()
365+
.some((s) => s.track?.id === 'stale')
366+
).toBe(false);
367+
});
368+
369+
it.each(['publish', 'unpublish'] as const)(
370+
'continues %s for later viewers when one leaves during replaceTrack',
371+
async (operation) => {
372+
const { result, peer } = await setup();
373+
for (const id of ['first', 'middle', 'last']) {
374+
await join(id);
375+
await answer(id);
376+
}
377+
const oldVideo = track('old-screen', 'video');
378+
const screenAudio = track('screen-audio');
379+
await act(async () => {
380+
await result.current.publishStream(new MediaStream([oldVideo, screenAudio]));
381+
});
382+
for (const id of ['first', 'middle', 'last']) await answer(id);
383+
const middle = peer('middle');
384+
const sender = middle.senders.find(
385+
(item) => item.track?.kind === (operation === 'publish' ? 'video' : 'audio')
386+
)!;
387+
const pending = deferred<undefined>();
388+
sender.replaceTrack.mockImplementationOnce(async (replacement: MediaStreamTrack) => {
389+
await pending.promise;
390+
sender.track = replacement;
391+
});
392+
const newVideo = track('new-screen', 'video');
393+
let updating!: Promise<void>;
394+
await act(async () => {
395+
updating =
396+
operation === 'publish'
397+
? result.current.publishStream(new MediaStream([newVideo, screenAudio]))
398+
: result.current.unpublishStream();
399+
await flush();
400+
});
401+
expect(sender.replaceTrack).toHaveBeenCalled();
402+
await emit('presence-leave', { presences: [{ user_id: 'middle' }] });
403+
await act(async () => {
404+
pending.resolve(undefined);
405+
await updating;
406+
});
407+
const videos = peer('last').senders.filter((item) => item.track?.kind === 'video');
408+
expect(videos.map((item) => item.track)).toEqual(operation === 'publish' ? [newVideo] : []);
409+
}
410+
);
411+
412+
it('keeps the presentation stream across renders and excludes cancelled streams from late joins', async () => {
413+
const { result, peer, rerender } = await setup();
414+
const video = track('presentation', 'video');
415+
let cancelled = false;
416+
await act(async () => {
417+
await result.current.publishStream(new MediaStream([video]), () => cancelled);
418+
});
419+
rerender();
420+
await join('late');
421+
expect(peer('late').senders.some((sender) => sender.track === video)).toBe(true);
422+
cancelled = true;
423+
await join('after-cancel');
424+
expect(peer('after-cancel').senders.some((sender) => sender.track?.kind === 'video')).toBe(
425+
false
426+
);
427+
await act(async () => {
428+
await result.current.unpublishStream();
429+
});
430+
rerender();
431+
await join('after-stop');
432+
expect(peer('after-stop').senders.some((sender) => sender.track?.kind === 'video')).toBe(false);
433+
});
434+
204435
it('does not time out a participant while the final answer is being applied', async () => {
205436
vi.useFakeTimers();
206437
const { result, peer } = await setup();

0 commit comments

Comments
 (0)