diff --git a/.changeset/weak-pugs-push.md b/.changeset/weak-pugs-push.md new file mode 100644 index 000000000..594856c52 --- /dev/null +++ b/.changeset/weak-pugs-push.md @@ -0,0 +1,5 @@ +--- +"@whereby.com/browser-sdk": patch +--- + +Fix issue which kept camera light on after disabling video diff --git a/packages/browser-sdk/src/lib/__mocks__/MediaStream.ts b/packages/browser-sdk/src/lib/__mocks__/MediaStream.ts index f0af4b1d3..d8a3cec80 100644 --- a/packages/browser-sdk/src/lib/__mocks__/MediaStream.ts +++ b/packages/browser-sdk/src/lib/__mocks__/MediaStream.ts @@ -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(); } diff --git a/packages/browser-sdk/src/lib/core/redux/slices/localMedia.ts b/packages/browser-sdk/src/lib/core/redux/slices/localMedia.ts index cb18679ea..a864f6cdb 100644 --- a/packages/browser-sdk/src/lib/core/redux/slices/localMedia.ts +++ b/packages/browser-sdk/src/lib/core/redux/slices/localMedia.ts @@ -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(); diff --git a/packages/browser-sdk/src/lib/core/redux/tests/store/localMedia.spec.ts b/packages/browser-sdk/src/lib/core/redux/tests/store/localMedia.spec.ts index 697fe89a5..185eac680 100644 --- a/packages/browser-sdk/src/lib/core/redux/tests/store/localMedia.spec.ts +++ b/packages/browser-sdk/src/lib/core/redux/tests/store/localMedia.spec.ts @@ -157,6 +157,121 @@ describe("actions", () => { }); }); + describe("doToggleCamera", () => { + describe("when camera is enabled", () => { + let audioTrack: MediaStreamTrack; + let initialState: Partial; + 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; + 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 = {