diff --git a/src/lib/core/redux/slices/localMedia.ts b/src/lib/core/redux/slices/localMedia.ts index cdb710d1..f6bfeb8e 100644 --- a/src/lib/core/redux/slices/localMedia.ts +++ b/src/lib/core/redux/slices/localMedia.ts @@ -1,4 +1,4 @@ -import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { createSelector, createSlice, isAnyOf, PayloadAction } from "@reduxjs/toolkit"; import { getStream, getUpdatedDevices, getDeviceData } from "@whereby/jslib-media/src/webrtc/MediaDevices"; import { createAppAsyncThunk, createAppThunk } from "../../redux/thunk"; import { RootState } from "../../redux/store"; @@ -16,6 +16,7 @@ export type LocalMediaOptions = { */ export interface LocalMediaState { + busyDeviceIds: string[]; cameraDeviceError?: unknown; cameraEnabled: boolean; currentCameraDeviceId?: string; @@ -35,6 +36,7 @@ export interface LocalMediaState { } export const initialState: LocalMediaState = { + busyDeviceIds: [], cameraEnabled: false, devices: [], isSettingCameraDevice: false, @@ -49,6 +51,16 @@ export const localMediaSlice = createSlice({ name: "localMedia", initialState, reducers: { + deviceBusy(state, action: PayloadAction<{ deviceId: string }>) { + if (state.busyDeviceIds.includes(action.payload.deviceId)) { + return state; + } + + return { + ...state, + busyDeviceIds: [...state.busyDeviceIds, action.payload.deviceId], + }; + }, toggleCameraEnabled(state, action: PayloadAction<{ enabled?: boolean }>) { return { ...state, @@ -98,6 +110,15 @@ export const localMediaSlice = createSlice({ stream: undefined, }; }, + localStreamMetadataUpdated(state, action: PayloadAction>) { + const { audio, video } = action.payload; + return { + ...state, + currentCameraDeviceId: video.deviceId, + currentMicrophoneDeviceId: audio.deviceId, + busyDeviceIds: state.busyDeviceIds.filter((id) => id !== audio.deviceId && id !== video.deviceId), + }; + }, }, extraReducers: (builder) => { builder.addCase(doAppJoin, (state, action) => { @@ -157,30 +178,24 @@ export const localMediaSlice = createSlice({ }; }); builder.addCase(doStartLocalMedia.fulfilled, (state, { payload: { stream, onDeviceChange } }) => { - let cameraDeviceId = undefined; let cameraEnabled = false; - let microphoneDeviceId = undefined; let microphoneEnabled = false; const audioTrack = stream.getAudioTracks()[0]; const videoTrack = stream.getVideoTracks()[0]; if (audioTrack) { - microphoneDeviceId = audioTrack.getSettings().deviceId; microphoneEnabled = audioTrack.enabled; } if (videoTrack) { cameraEnabled = videoTrack.enabled; - cameraDeviceId = videoTrack.getSettings().deviceId; } return { ...state, stream, status: "started", - currentCameraDeviceId: cameraDeviceId, - currentMicrophoneDeviceId: microphoneDeviceId, cameraEnabled, microphoneEnabled, onDeviceChange, @@ -200,17 +215,9 @@ export const localMediaSlice = createSlice({ }; }); builder.addCase(doSwitchLocalStream.fulfilled, (state) => { - const deviceData = getDeviceData({ - devices: state.devices, - audioTrack: state.stream?.getAudioTracks()[0], - videoTrack: state.stream?.getVideoTracks()[0], - }); - return { ...state, isSwitchingStream: false, - currentCameraDeviceId: deviceData.video.deviceId, - currentMicrophoneDeviceId: deviceData.audio.deviceId, }; }); builder.addCase(doSwitchLocalStream.rejected, (state) => { @@ -227,6 +234,7 @@ export const localMediaSlice = createSlice({ */ export const { + deviceBusy, setCurrentCameraDeviceId, setCurrentMicrophoneDeviceId, toggleCameraEnabled, @@ -234,6 +242,7 @@ export const { setLocalMediaOptions, setLocalMediaStream, localMediaStopped, + localStreamMetadataUpdated, } = localMediaSlice.actions; const doToggleCamera = createAppAsyncThunk("localMedia/doToggleCamera", async (_, { getState, rejectWithValue }) => { @@ -347,7 +356,7 @@ export const doUpdateDeviceList = createAppAsyncThunk( let newDevices: MediaDeviceInfo[] = []; let oldDevices: MediaDeviceInfo[] = []; const stream = selectLocalMediaStream(state); - + const busy = selectBusyDeviceIds(state); try { newDevices = await navigator.mediaDevices.enumerateDevices(); oldDevices = selectLocalMediaDevices(state); @@ -383,7 +392,10 @@ export const doUpdateDeviceList = createAppAsyncThunk( const videoDevices = selectLocalMediaDevices(state).filter((d) => d.kind === "videoinput"); const videoId = selectCurrentCameraDeviceId(state); - let nextVideoId = nextId(videoDevices, videoId); + let nextVideoId = nextId( + videoDevices.filter((d) => !busy.includes(d.deviceId)), + videoId + ); if (!nextVideoId || videoId === nextVideoId) { nextVideoId = nextId(videoDevices, videoId); } @@ -396,7 +408,10 @@ export const doUpdateDeviceList = createAppAsyncThunk( const audioDevices = selectLocalMediaDevices(state).filter((d) => d.kind === "audioinput"); const audioId = selectCurrentMicrophoneDeviceId(state); - let nextAudioId = nextId(audioDevices, audioId); + let nextAudioId = nextId( + audioDevices.filter((d) => !busy.includes(d.deviceId)), + audioId + ); if (!nextAudioId || audioId === nextAudioId) { nextAudioId = nextId(audioDevices, audioId); } @@ -418,10 +433,11 @@ export const doUpdateDeviceList = createAppAsyncThunk( export const doSwitchLocalStream = createAppAsyncThunk( "localMedia/doSwitchLocalStream", - async ({ audioId, videoId }: { audioId?: string; videoId?: string }, { getState, rejectWithValue }) => { + async ({ audioId, videoId }: { audioId?: string; videoId?: string }, { dispatch, getState, rejectWithValue }) => { const state = getState(); const replaceStream = selectLocalMediaStream(state); const constraintsOptions = selectLocalMediaConstraintsOptions(state); + const onlySwitchingOne = !!(videoId && !audioId) || !!(!videoId && audioId); if (!replaceStream) { // Switching no stream makes no sense return; @@ -438,9 +454,26 @@ export const doSwitchLocalStream = createAppAsyncThunk( { replaceStream } ); + const deviceId = audioId || videoId; + if (onlySwitchingOne && deviceId) { + dispatch( + deviceBusy({ + deviceId, + }) + ); + } + return { replacedTracks }; } catch (error) { console.error(error); + const deviceId = audioId || videoId; + if (onlySwitchingOne && deviceId) { + dispatch( + deviceBusy({ + deviceId, + }) + ); + } return rejectWithValue(error); } } @@ -511,6 +544,7 @@ export const doStopLocalMedia = createAppThunk(() => (dispatch, getState) => { * Selectors */ +export const selectBusyDeviceIds = (state: RootState) => state.localMedia.busyDeviceIds; export const selectCameraDeviceError = (state: RootState) => state.localMedia.cameraDeviceError; export const selectCurrentCameraDeviceId = (state: RootState) => state.localMedia.currentCameraDeviceId; export const selectCurrentMicrophoneDeviceId = (state: RootState) => state.localMedia.currentMicrophoneDeviceId; @@ -541,11 +575,17 @@ export const selectLocalMediaConstraintsOptions = createSelector(selectLocalMedi }, })); export const selectIsLocalMediaStarting = createSelector(selectLocalMediaStatus, (status) => status === "starting"); -export const selectCameraDevices = createSelector(selectLocalMediaDevices, (devices) => - devices.filter((d) => d.kind === "videoinput") +export const selectCameraDevices = createSelector( + selectLocalMediaDevices, + selectBusyDeviceIds, + (devices, busyDeviceIds) => + devices.filter((d) => d.kind === "videoinput").filter((d) => !busyDeviceIds.includes(d.deviceId)) ); -export const selectMicrophoneDevices = createSelector(selectLocalMediaDevices, (devices) => - devices.filter((d) => d.kind === "audioinput") +export const selectMicrophoneDevices = createSelector( + selectLocalMediaDevices, + selectBusyDeviceIds, + (devices, busyDeviceIds) => + devices.filter((d) => d.kind === "audioinput").filter((d) => !busyDeviceIds.includes(d.deviceId)) ); export const selectSpeakerDevices = createSelector(selectLocalMediaDevices, (devices) => devices.filter((d) => d.kind === "audiooutput") @@ -640,3 +680,27 @@ startAppListening({ dispatch(doSetDevice({ audio: true, video: false })); }, }); + +startAppListening({ + matcher: isAnyOf( + doStartLocalMedia.fulfilled, + doUpdateDeviceList.fulfilled, + doSwitchLocalStream.fulfilled, + doSwitchLocalStream.rejected + ), + effect: (_action, { dispatch, getState }) => { + const state = getState(); + const stream = selectLocalMediaStream(state); + const devices = selectLocalMediaDevices(state); + + if (!stream) return; + + const deviceData = getDeviceData({ + audioTrack: stream.getAudioTracks()[0], + videoTrack: stream.getVideoTracks()[0], + devices, + }); + + dispatch(localStreamMetadataUpdated(deviceData)); + }, +}); diff --git a/src/lib/core/redux/tests/store/localMedia.spec.ts b/src/lib/core/redux/tests/store/localMedia.spec.ts index 9aa5b0c0..697fe89a 100644 --- a/src/lib/core/redux/tests/store/localMedia.spec.ts +++ b/src/lib/core/redux/tests/store/localMedia.spec.ts @@ -1,12 +1,13 @@ -import { doStartLocalMedia, doStopLocalMedia } from "../../slices/localMedia"; +import * as localMediaSlice from "../../slices/localMedia"; import { createStore } from "../store.setup"; import { diff } from "deep-object-diff"; -import { getStream } from "@whereby/jslib-media/src/webrtc/MediaDevices"; +import * as MediaDevices from "@whereby/jslib-media/src/webrtc/MediaDevices"; import MockMediaStream from "../../../../__mocks__/MediaStream"; import MockMediaStreamTrack from "../../../../__mocks__/MediaStreamTrack"; import mockMediaDevices from "../../../../__mocks__/mediaDevices"; import { RootState } from "../../store"; +import { randomString } from "../../../../__mocks__/appMocks"; Object.defineProperty(window, "MediaStream", { writable: true, @@ -26,9 +27,11 @@ Object.defineProperty(navigator, "mediaDevices", { jest.mock("@whereby/jslib-media/src/webrtc/MediaDevices", () => ({ __esModule: true, getStream: jest.fn(() => Promise.resolve()), + getUpdatedDevices: jest.fn(() => Promise.resolve({ addedDevices: {}, changedDevices: {} })), })); -const mockedGetStream = jest.mocked(getStream); +const mockedGetStream = jest.mocked(MediaDevices.getStream); +const mockedEnumerateDevices = jest.mocked(navigator.mediaDevices.enumerateDevices); describe("actions", () => { describe("doStartLocalMedia", () => { @@ -42,7 +45,7 @@ describe("actions", () => { it("should NOT get stream", async () => { const store = createStore(); - await store.dispatch(doStartLocalMedia(existingStream)); + await store.dispatch(localMediaSlice.doStartLocalMedia(existingStream)); expect(mockedGetStream).toHaveBeenCalledTimes(0); }); @@ -52,7 +55,7 @@ describe("actions", () => { const before = store.getState().localMedia; - await store.dispatch(doStartLocalMedia(existingStream)); + await store.dispatch(localMediaSlice.doStartLocalMedia(existingStream)); const after = store.getState().localMedia; @@ -68,7 +71,7 @@ describe("actions", () => { it("should call getStream", async () => { const store = createStore(); - await store.dispatch(doStartLocalMedia({ audio: true, video: true })); + await store.dispatch(localMediaSlice.doStartLocalMedia({ audio: true, video: true })); expect(mockedGetStream).toHaveBeenCalledTimes(1); }); @@ -86,7 +89,7 @@ describe("actions", () => { const before = store.getState().localMedia; - await store.dispatch(doStartLocalMedia({ audio: true, video: true })); + await store.dispatch(localMediaSlice.doStartLocalMedia({ audio: true, video: true })); const after = store.getState().localMedia; @@ -114,6 +117,7 @@ describe("actions", () => { initialState = { localMedia: { + busyDeviceIds: [], cameraEnabled: true, devices: [], isSettingCameraDevice: false, @@ -130,7 +134,7 @@ describe("actions", () => { it("should stop all tracks in existing stream", () => { const store = createStore({ initialState }); - store.dispatch(doStopLocalMedia()); + store.dispatch(localMediaSlice.doStopLocalMedia()); expect(audioTrack.stop).toHaveBeenCalled(); expect(videoTrack.stop).toHaveBeenCalled(); @@ -141,7 +145,7 @@ describe("actions", () => { const before = store.getState().localMedia; - store.dispatch(doStopLocalMedia()); + store.dispatch(localMediaSlice.doStopLocalMedia()); const after = store.getState().localMedia; @@ -152,4 +156,141 @@ describe("actions", () => { }); }); }); + + describe("doUpdateDeviceList", () => { + it("should switch to the next video device if current cam is unplugged", async () => { + const dev1 = { + deviceId: "dev1", + kind: "videoinput" as const, + label: randomString("label"), + groupId: randomString("groupId"), + toJSON: () => ({}), + }; + const dev2 = { + deviceId: "dev2", + kind: "videoinput" as const, + label: randomString("label"), + groupId: randomString("groupId"), + toJSON: () => ({}), + }; + + const store = createStore({ + initialState: { + localMedia: { + busyDeviceIds: [], + currentCameraDeviceId: dev2.deviceId, + cameraEnabled: true, + devices: [dev1, dev2], + isSettingCameraDevice: false, + isSettingMicrophoneDevice: false, + isTogglingCamera: false, + microphoneEnabled: true, + status: "started", + stream: new MockMediaStream(), + isSwitchingStream: false, + }, + }, + }); + jest.spyOn(localMediaSlice, "doSwitchLocalStream"); + jest.spyOn(MediaDevices, "getUpdatedDevices").mockImplementationOnce(() => ({ + addedDevices: {}, + changedDevices: { videoinput: dev2 }, + })); + + mockedEnumerateDevices.mockImplementationOnce(() => Promise.resolve([dev1])); + + const before = store.getState().localMedia; + + await store.dispatch(localMediaSlice.doUpdateDeviceList()); + + const after = store.getState().localMedia; + + expect(mockedEnumerateDevices).toHaveBeenCalled(); + expect(localMediaSlice.doSwitchLocalStream).toHaveBeenCalledWith({ + audioId: undefined, + videoId: dev1.deviceId, + }); + expect(diff(before, after)).toMatchObject({ + devices: { + 1: undefined, + }, + }); + }); + + it("should skip busy devices", async () => { + const videoId = randomString("videoDeviceId"); + const videoId2 = randomString("videoDeviceId2"); + const videoId3 = randomString("videoDeviceId3"); + + const dev1 = { + deviceId: videoId, + kind: "videoinput" as const, + label: randomString("label"), + groupId: randomString("groupId"), + toJSON: () => ({}), + }; + const dev2 = { + deviceId: videoId2, + kind: "videoinput" as const, + label: randomString("label"), + groupId: randomString("groupId"), + toJSON: () => ({}), + }; + const dev3 = { + deviceId: videoId3, + kind: "videoinput" as const, + label: randomString("label"), + groupId: randomString("groupId"), + toJSON: () => ({}), + }; + + const stream = new MockMediaStream(); + jest.spyOn(MediaDevices, "getStream").mockResolvedValueOnce({ stream }); + + const store = createStore({ + initialState: { + localMedia: { + busyDeviceIds: [videoId2], + currentCameraDeviceId: videoId, + cameraEnabled: true, + devices: [dev1, dev2, dev3], + isSettingCameraDevice: false, + isSettingMicrophoneDevice: false, + isTogglingCamera: false, + microphoneEnabled: true, + status: "started", + stream, + isSwitchingStream: false, + }, + }, + }); + jest.spyOn(localMediaSlice, "doSwitchLocalStream"); + jest.spyOn(MediaDevices, "getUpdatedDevices").mockImplementationOnce(() => ({ + addedDevices: {}, + changedDevices: { videoinput: dev3 }, + })); + + mockedEnumerateDevices.mockImplementationOnce(() => Promise.resolve([dev1, dev2])); + + const before = store.getState().localMedia; + + await store.dispatch(localMediaSlice.doUpdateDeviceList()); + + const after = store.getState().localMedia; + + expect(diff(before, after)).toMatchObject({ + busyDeviceIds: { + 1: expect.any(String), + }, + devices: { + 2: undefined, + }, + }); + expect(localMediaSlice.doSwitchLocalStream).toHaveBeenCalledWith({ + audioId: undefined, + videoId: videoId3, + }); + expect(mockedEnumerateDevices).toHaveBeenCalled(); + }); + }); });