Skip to content

Commit 948f672

Browse files
authored
fix: ensure tracks are stopped when disposing a Publisher (#1676)
In some circumstances, we were dangling track clones. No data flowed to the SFU, but the browser kept the camera/mic indicators on as there were MediaStreamTrack instances that weren't stopped properly. ### Implementation notes Upon disposing of a `Publisher`, we now iterate through all transceivers and we explicitly stop the assigned track. This PR also fixes another issue that could lead to dangling, unstopped tracks when replacing a track on an existing transceiver.
1 parent c86a96c commit 948f672

File tree

9 files changed

+76
-27
lines changed

9 files changed

+76
-27
lines changed

packages/client/src/rtc/BasePeerConnection.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,12 @@ export abstract class BasePeerConnection {
7575
/**
7676
* Disposes the `RTCPeerConnection` instance.
7777
*/
78-
dispose = () => {
78+
dispose() {
7979
this.onUnrecoverableError = undefined;
8080
this.isDisposed = true;
8181
this.detachEventHandlers();
8282
this.pc.close();
83-
};
83+
}
8484

8585
/**
8686
* Detaches the event handlers from the `RTCPeerConnection`.

packages/client/src/rtc/Publisher.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ export class Publisher extends BasePeerConnection {
7373
withCancellation('publisher.negotiate', () => Promise.resolve());
7474
}
7575

76+
/**
77+
* Disposes this Publisher instance.
78+
*/
79+
dispose() {
80+
super.dispose();
81+
this.stopAllTracks();
82+
}
83+
7684
/**
7785
* Starts publishing the given track of the given media stream.
7886
*
@@ -98,7 +106,9 @@ export class Publisher extends BasePeerConnection {
98106
if (!transceiver) {
99107
this.addTransceiver(trackToPublish, publishOption);
100108
} else {
109+
const previousTrack = transceiver.sender.track;
101110
await transceiver.sender.replaceTrack(trackToPublish);
111+
previousTrack?.stop();
102112
}
103113
}
104114
};
@@ -203,6 +213,15 @@ export class Publisher extends BasePeerConnection {
203213
}
204214
};
205215

216+
/**
217+
* Stops all the cloned tracks that are being published to the SFU.
218+
*/
219+
stopAllTracks = () => {
220+
for (const { transceiver } of this.transceiverCache.items()) {
221+
transceiver.sender.track?.stop();
222+
}
223+
};
224+
206225
private changePublishQuality = async (videoSender: VideoSender) => {
207226
const { trackType, layers, publishOptionId } = videoSender;
208227
const enabledLayers = layers.filter((l) => l.active);

packages/client/src/rtc/__tests__/Publisher.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ describe('Publisher', () => {
124124
vi.spyOn(track, 'clone').mockReturnValue(clone);
125125

126126
const transceiver = new RTCRtpTransceiver();
127+
// @ts-ignore test setup
128+
transceiver.sender.track = track;
127129
publisher['transceiverCache'].add(
128130
publisher['publishOptions'][0],
129131
transceiver,
@@ -134,6 +136,7 @@ describe('Publisher', () => {
134136
expect(track.clone).toHaveBeenCalled();
135137
expect(publisher['pc'].addTransceiver).not.toHaveBeenCalled();
136138
expect(transceiver.sender.replaceTrack).toHaveBeenCalledWith(clone);
139+
expect(track.stop).toHaveBeenCalled();
137140
});
138141
});
139142

@@ -701,5 +704,12 @@ describe('Publisher', () => {
701704
publisher.stopTracks(TrackType.VIDEO);
702705
expect(track!.stop).toHaveBeenCalled();
703706
});
707+
708+
it('stopAllTracks should stop all tracks', () => {
709+
const track = cache['cache'][0].transceiver.sender.track;
710+
vi.spyOn(track, 'stop');
711+
publisher.stopAllTracks();
712+
expect(track!.stop).toHaveBeenCalled();
713+
});
704714
});
705715
});

packages/client/src/rtc/__tests__/mocks/webrtc.mocks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const RTCPeerConnectionMock = vi.fn((): Partial<RTCPeerConnection> => {
66
addIceCandidate: vi.fn(),
77
removeEventListener: vi.fn(),
88
getTransceivers: vi.fn(),
9-
addTransceiver: vi.fn(),
9+
addTransceiver: vi.fn().mockReturnValue(new RTCRtpTransceiverMock()),
1010
getConfiguration: vi.fn(),
1111
setConfiguration: vi.fn(),
1212
createOffer: vi.fn().mockResolvedValue({}),

sample-apps/client/ts-quickstart/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@
99
"preview": "vite preview"
1010
},
1111
"dependencies": {
12-
"@stream-io/video-client": "workspace:^",
13-
"js-base64": "^3.7.5"
12+
"@stream-io/video-client": "workspace:^"
1413
},
1514
"devDependencies": {
1615
"@vitejs/plugin-basic-ssl": "^1.0.1",

sample-apps/client/ts-quickstart/src/controls.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Call } from '@stream-io/video-client';
1+
import { Call, CallingState } from '@stream-io/video-client';
22

33
const renderAudioButton = (call: Call) => {
44
const audioButton = document.createElement('button');
@@ -59,12 +59,33 @@ const renderFlipButton = (call: Call) => {
5959

6060
return flipButton;
6161
};
62+
const renderLeaveJoinButton = (call: Call) => {
63+
const btn = document.createElement('button');
64+
btn.addEventListener('click', () => {
65+
if (call.state.callingState === CallingState.LEFT) {
66+
call.join();
67+
} else {
68+
call.leave();
69+
}
70+
});
71+
72+
call.state.callingState$.subscribe((s) => {
73+
if (s !== CallingState.JOINED) {
74+
btn.innerText = 'Join';
75+
} else {
76+
btn.innerText = 'Leave';
77+
}
78+
});
79+
80+
return btn;
81+
};
6282

6383
export const renderControls = (call: Call) => {
6484
return {
6585
audioButton: renderAudioButton(call),
6686
videoButton: renderVideoButton(call),
6787
screenShareButton: renderScreenShareButton(call),
6888
flipButton: renderFlipButton(call),
89+
leaveJoinCallButton: renderLeaveJoinButton(call),
6990
};
7091
};

sample-apps/client/ts-quickstart/src/main.ts

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import './style.css';
2-
import { StreamVideoClient, User } from '@stream-io/video-client';
3-
import { decode } from 'js-base64';
2+
import { CallingState, StreamVideoClient } from '@stream-io/video-client';
43
import { cleanupParticipant, renderParticipant } from './participant';
54
import { renderControls } from './controls';
65
import {
@@ -12,33 +11,29 @@ import {
1211
import { isMobile } from './mobile';
1312
import { ClosedCaptionManager } from './closed-captions';
1413

15-
const searchParams = new URLSearchParams(window.location.search);
16-
const extractPayloadFromToken = (token: string) => {
17-
const [, payload] = token.split('.');
18-
19-
if (!payload) throw new Error('Malformed token, missing payload');
20-
21-
return (JSON.parse(decode(payload)) ?? {}) as Record<string, unknown>;
22-
};
23-
24-
const apiKey = import.meta.env.VITE_STREAM_API_KEY;
25-
const token = searchParams.get('ut') ?? import.meta.env.VITE_STREAM_USER_TOKEN;
26-
const user: User = {
27-
id: extractPayloadFromToken(token)['user_id'] as string,
28-
};
14+
const ENVIRONMENT = 'demo';
15+
const userId = 'luke';
16+
const credentials = (await fetch(
17+
`https://pronto.getstream.io/api/auth/create-token?environment=${ENVIRONMENT}&user_id=${userId}`,
18+
).then((res) => res.json())) as { apiKey: string; token: string };
2919

20+
const searchParams = new URLSearchParams(window.location.search);
3021
const callId =
3122
searchParams.get('call_id') ||
3223
import.meta.env.VITE_STREAM_CALL_ID ||
3324
(new Date().getTime() + Math.round(Math.random() * 100)).toString();
3425

3526
const client = new StreamVideoClient({
36-
apiKey,
37-
token,
38-
user,
39-
options: { logLevel: import.meta.env.VITE_STREAM_LOG_LEVEL },
27+
apiKey: credentials.apiKey,
28+
token: credentials.token,
29+
user: { id: userId, name: 'Luke' },
30+
options: { logLevel: 'debug' },
4031
});
32+
4133
const call = client.call('default', callId);
34+
await call.camera.enable();
35+
await call.microphone.disableSpeakingWhileMutedNotification();
36+
await call.microphone.enable();
4237

4338
// @ts-ignore
4439
window.call = call;
@@ -55,6 +50,7 @@ const container = document.getElementById('call-controls')!;
5550

5651
// render mic and camera controls
5752
const controls = renderControls(call);
53+
container.appendChild(controls.leaveJoinCallButton);
5854
container.appendChild(controls.audioButton);
5955
container.appendChild(controls.videoButton);
6056
container.appendChild(controls.screenShareButton);
@@ -95,6 +91,8 @@ const parentContainer = document.getElementById('participants')!;
9591
call.setViewport(parentContainer);
9692

9793
call.state.participants$.subscribe((participants) => {
94+
if (call.state.callingState === CallingState.LEFT) return;
95+
9896
// render / update existing participants
9997
participants.forEach((participant) => {
10098
renderParticipant(call, participant, parentContainer, screenShareContainer);

sample-apps/client/ts-quickstart/vite.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,7 @@ import basicSsl from '@vitejs/plugin-basic-ssl';
22

33
export default {
44
plugins: [basicSsl()],
5+
build: {
6+
target: 'esnext',
7+
},
58
};

yarn.lock

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8212,7 +8212,6 @@ __metadata:
82128212
dependencies:
82138213
"@stream-io/video-client": "workspace:^"
82148214
"@vitejs/plugin-basic-ssl": ^1.0.1
8215-
js-base64: ^3.7.5
82168215
typescript: ^5.5.2
82178216
vite: ^5.4.6
82188217
languageName: unknown

0 commit comments

Comments
 (0)