Skip to content

Commit

Permalink
browser-sdk: Stop video tracks when disabling camera
Browse files Browse the repository at this point in the history
This fixes a bug which prevented us from stopping the video tracks when
the camera was disabled.
  • Loading branch information
havardholvik committed Feb 13, 2024
1 parent 493762d commit fd6c24f
Show file tree
Hide file tree
Showing 4 changed files with 163 additions and 48 deletions.
5 changes: 5 additions & 0 deletions .changeset/weak-pugs-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@whereby.com/browser-sdk": patch
---

Fix issue which kept camera light on after disabling video
4 changes: 1 addition & 3 deletions packages/browser-sdk/src/lib/__mocks__/MediaStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,5 @@ export default class MockMediaStream implements MediaStream {
removeEventListener(type: unknown, listener: unknown, options?: unknown): void {
throw new Error(`Method not implemented. ${type}, ${listener}, ${options}`);
}
dispatchEvent(event: Event): boolean {
throw new Error(`Method not implemented. ${event}`);
}
dispatchEvent = jest.fn();
}
87 changes: 42 additions & 45 deletions packages/browser-sdk/src/lib/core/redux/slices/localMedia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,58 +251,55 @@ export const {
localStreamMetadataUpdated,
} = localMediaSlice.actions;

const doToggleCamera = createAppAsyncThunk("localMedia/doToggleCamera", async (_, { getState, rejectWithValue }) => {
const state = getState();
const stream = selectLocalMediaStream(state);
if (!stream) {
return;
}
let track = stream.getVideoTracks()[0];
const enabled = selectIsCameraEnabled(state);

// Only stop tracks if we fully own the media stream
const shouldStopTrack = selectLocalMediaOwnsStream(state);
export const doToggleCamera = createAppAsyncThunk(
"localMedia/doToggleCamera",
async (_, { getState, rejectWithValue }) => {
const state = getState();
const stream = selectLocalMediaStream(state);
if (!stream) {
return;
}
let track = stream.getVideoTracks()[0];
const enabled = selectIsCameraEnabled(state);

try {
if (enabled) {
if (track) {
// We have existing video track, just enable it
track.enabled = true;
try {
if (enabled) {
if (track) {
// We have existing video track, just enable it
track.enabled = true;
} else {
// We dont have video track, get new one
const constraintsOptions = selectLocalMediaConstraintsOptions(state);
const cameraDeviceId = selectCurrentCameraDeviceId(state);
await getStream(
{
...constraintsOptions,
audioId: false,
videoId: cameraDeviceId,
type: "exact",
},
{ replaceStream: stream },
);

track = stream.getVideoTracks()[0];
}
} else {
// We dont have video track, get new one
const constraintsOptions = selectLocalMediaConstraintsOptions(state);
const cameraDeviceId = selectCurrentCameraDeviceId(state);
await getStream(
{
...constraintsOptions,
audioId: false,
videoId: cameraDeviceId,
type: "exact",
},
{ replaceStream: stream },
);

track = stream.getVideoTracks()[0];
}
} else {
if (!track) {
return;
}

track.enabled = false;
if (!track) {
return;
}

if (shouldStopTrack) {
track.enabled = false;
track.stop();
stream.removeTrack(track);
}
}

// Dispatch event on stream to allow RTC layer effects
stream.dispatchEvent(new CustomEvent("stopresumevideo", { detail: { track, enable: enabled } }));
} catch (error) {
return rejectWithValue(error);
}
});
// Dispatch event on stream to allow RTC layer effects
stream.dispatchEvent(new CustomEvent("stopresumevideo", { detail: { track, enable: enabled } }));
} catch (error) {
return rejectWithValue(error);
}
},
);

const doToggleMicrophone = createAppAsyncThunk("localMedia/doToggleMicrophone", (_, { getState }) => {
const state = getState();
Expand Down
115 changes: 115 additions & 0 deletions packages/browser-sdk/src/lib/core/redux/tests/store/localMedia.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,121 @@ describe("actions", () => {
});
});

describe("doToggleCamera", () => {
describe("when camera is enabled", () => {
let audioTrack: MediaStreamTrack;
let initialState: Partial<RootState>;
let localStream: MediaStream;

beforeEach(() => {
audioTrack = new MockMediaStreamTrack("audio");
localStream = new MockMediaStream([audioTrack]);

initialState = {
localMedia: {
busyDeviceIds: [],
cameraEnabled: true,
devices: [],
isSettingCameraDevice: false,
isSettingMicrophoneDevice: false,
isTogglingCamera: false,
microphoneEnabled: true,
status: "started",
stream: localStream,
isSwitchingStream: false,
},
};
});

it("should get new track and add it to existing stream", () => {
const store = createStore({ initialState });

store.dispatch(localMediaSlice.doToggleCamera());

expect(mockedGetStream).toHaveBeenCalledTimes(1);
});

it("should dispatch `stopresumevideo` on stream with new video track", async () => {
const store = createStore({ initialState });
const videoTrack = new MockMediaStreamTrack("video");
mockedGetStream.mockImplementationOnce(async (_, opts) => {
if (opts?.replaceStream) {
opts.replaceStream.addTrack(videoTrack);
}
return { stream: opts?.replaceStream || new MockMediaStream([videoTrack]) };
});

await store.dispatch(localMediaSlice.doToggleCamera());

expect(localStream.dispatchEvent).toHaveBeenCalledWith(
new CustomEvent("stopresumevideo", { detail: { track: videoTrack, enable: true } }),
);
});
});

describe("when camera is disabled", () => {
let audioTrack: MediaStreamTrack;
let videoTrack: MediaStreamTrack;
let initialState: Partial<RootState>;
let localStream: MediaStream;

beforeEach(() => {
audioTrack = new MockMediaStreamTrack("audio");
videoTrack = new MockMediaStreamTrack("video");
localStream = new MockMediaStream([audioTrack, videoTrack]);

initialState = {
localMedia: {
busyDeviceIds: [],
cameraEnabled: false,
devices: [],
isSettingCameraDevice: false,
isSettingMicrophoneDevice: false,
isTogglingCamera: false,
microphoneEnabled: true,
status: "started",
stream: localStream,
isSwitchingStream: false,
},
};
});

it("should disable video track", () => {
const store = createStore({ initialState });

store.dispatch(localMediaSlice.doToggleCamera());

expect(videoTrack.enabled).toBe(false);
});

it("should stop video track", async () => {
const store = createStore({ initialState });

store.dispatch(localMediaSlice.doToggleCamera());

expect(videoTrack.stop).toHaveBeenCalled();
});

it("should remove video track from stream", () => {
const store = createStore({ initialState });

store.dispatch(localMediaSlice.doToggleCamera());

expect(localStream.getVideoTracks()).toHaveLength(0);
});

it("should dispatch `stopresumevideo` on stream with stopped video track", () => {
const store = createStore({ initialState });

store.dispatch(localMediaSlice.doToggleCamera());

expect(localStream.dispatchEvent).toHaveBeenCalledWith(
new CustomEvent("stopresumevideo", { detail: { track: videoTrack, enable: false } }),
);
});
});
});

describe("doUpdateDeviceList", () => {
it("should switch to the next video device if current cam is unplugged", async () => {
const dev1 = {
Expand Down

0 comments on commit fd6c24f

Please sign in to comment.