Skip to content

Commit

Permalink
Fix auto switch device
Browse files Browse the repository at this point in the history
  • Loading branch information
thyal committed Jan 9, 2024
1 parent 9c48cfb commit 261509c
Show file tree
Hide file tree
Showing 4 changed files with 204 additions and 8 deletions.
153 changes: 145 additions & 8 deletions src/lib/core/redux/slices/localMedia.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit";
import { getStream } from "@whereby/jslib-media/src/webrtc/MediaDevices";
import { getStream, getUpdatedDevices, getDeviceData } from "@whereby/jslib-media/src/webrtc/MediaDevices";
import { createAppAsyncThunk, createAppThunk } from "../../redux/thunk";
import { RootState } from "../../redux/store";
import { createReactor, startAppListening } from "../../redux/listenerMiddleware";
import { doAppJoin, selectAppWantsToJoin } from "./app";
import debounce from "~/lib/utils/debounce";

export type LocalMediaOptions = {
audio: boolean;
Expand All @@ -30,6 +31,8 @@ export interface LocalMediaState {
status: "" | "stopped" | "starting" | "started" | "error";
startError?: unknown;
stream?: MediaStream;
isSwitchingStream: boolean;
onDeviceChange?: () => void;
}

export const initialState: LocalMediaState = {
Expand All @@ -40,6 +43,7 @@ export const initialState: LocalMediaState = {
isTogglingCamera: false,
microphoneEnabled: false,
status: "",
isSwitchingStream: false,
};

export const localMediaSlice = createSlice({
Expand Down Expand Up @@ -186,6 +190,32 @@ export const localMediaSlice = createSlice({
...state,
screenshareStream: undefined,
};
})
.addCase(doSwitchLocalStream.pending, (state) => {
return {
...state,
isSwitchingStream: true,
};
})
.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,
};
})
.addCase(doSwitchLocalStream.rejected, (state) => {
return {
...state,
isSwitchingStream: false,
};
});
},
});
Expand Down Expand Up @@ -309,14 +339,111 @@ export const doSetDevice = createAppAsyncThunk(
}
);

const doUpdateDeviceList = createAppAsyncThunk("localMedia/doUpdateDeviceList", async (_, { rejectWithValue }) => {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
return { devices };
} catch (error) {
return rejectWithValue(error);
export const doUpdateDeviceList = createAppAsyncThunk(
"localMedia/doUpdateDeviceList",
async (_, { getState, dispatch, rejectWithValue }) => {
const state = getState();
let newDevices: MediaDeviceInfo[] = [];
let oldDevices: MediaDeviceInfo[] = [];
const stream = selectLocalMediaStream(state);

try {
newDevices = await navigator.mediaDevices.enumerateDevices();
oldDevices = selectLocalMediaDevices(state);

const shouldHandleDeviceUpdate =
stream &&
!selectLocalMediaIsSwitchingStream(state) &&
newDevices &&
oldDevices &&
oldDevices.find((d) => d.deviceId);

if (!shouldHandleDeviceUpdate) {
return { devices: newDevices };
}

const { changedDevices } = getUpdatedDevices({
oldDevices,
newDevices,
currentAudioId: selectCurrentMicrophoneDeviceId(state),
currentVideoId: selectCurrentCameraDeviceId(state),
});

let autoSwitchAudioId = changedDevices.audioinput?.deviceId;
let autoSwitchVideoId = changedDevices.videoinput?.deviceId;

// eslint-disable-next-line no-inner-declarations
function nextId(devices: MediaDeviceInfo[], id?: string) {
const curIdx = id ? devices.findIndex((d) => d.deviceId === id) : 0;
return (devices[(curIdx + 1) % devices.length] || {}).deviceId;
}

if (autoSwitchVideoId !== undefined) {
const videoDevices = selectLocalMediaDevices(state).filter((d) => d.kind === "videoinput");
const videoId = selectCurrentCameraDeviceId(state);

let nextVideoId = nextId(videoDevices, videoId);
if (!nextVideoId || videoId === nextVideoId) {
nextVideoId = nextId(videoDevices, videoId);
}
if (videoId !== nextVideoId) {
autoSwitchVideoId = nextVideoId;
}
}

if (autoSwitchAudioId !== undefined) {
const audioDevices = selectLocalMediaDevices(state).filter((d) => d.kind === "audioinput");
const audioId = selectCurrentMicrophoneDeviceId(state);

let nextAudioId = nextId(audioDevices, audioId);
if (!nextAudioId || audioId === nextAudioId) {
nextAudioId = nextId(audioDevices, audioId);
}
if (audioId !== nextAudioId) {
autoSwitchAudioId = nextAudioId;
}
}

if (autoSwitchAudioId !== undefined || autoSwitchVideoId !== undefined) {
dispatch(doSwitchLocalStream({ audioId: autoSwitchAudioId, videoId: autoSwitchVideoId }));
}

return { devices: newDevices };
} catch (error) {
return rejectWithValue(error);
}
}
});
);

export const doSwitchLocalStream = createAppAsyncThunk(
"localMedia/doSwitchLocalStream",
async ({ audioId, videoId }: { audioId?: string; videoId?: string }, { getState, rejectWithValue }) => {
const state = getState();
const replaceStream = selectLocalMediaStream(state);
const constraintsOptions = selectLocalMediaConstraintsOptions(state);
if (!replaceStream) {
// Switching no stream makes no sense
return;
}

try {
const { replacedTracks } = await getStream(
{
...constraintsOptions,
audioId: audioId === undefined ? false : audioId,
videoId: videoId === undefined ? false : videoId,
type: "exact",
},
{ replaceStream }
);

return { replacedTracks };
} catch (error) {
console.error(error);
return rejectWithValue(error);
}
}
);

export const doStartLocalMedia = createAppAsyncThunk(
"localMedia/doStartLocalMedia",
Expand All @@ -326,6 +453,15 @@ export const doStartLocalMedia = createAppAsyncThunk(
return Promise.resolve({ stream: payload });
}

const onDeviceChange = debounce(
() => {
dispatch(doUpdateDeviceList());
},
{ delay: 500 }
);

global.navigator.mediaDevices && global.navigator.mediaDevices.addEventListener("devicechange", onDeviceChange);

if (!(payload.audio || payload.video)) {
return { stream: new MediaStream() };
} else {
Expand Down Expand Up @@ -433,6 +569,7 @@ export const selectLocalMediaStream = (state: RootState) => state.localMedia.str
export const selectMicrophoneDeviceError = (state: RootState) => state.localMedia.microphoneDeviceError;
export const selectScreenshareStream = (state: RootState) => state.localMedia.screenshareStream;
export const selectLocalMediaStartError = (state: RootState) => state.localMedia.startError;
export const selectLocalMediaIsSwitchingStream = (state: RootState) => state.localMedia.isSwitchingStream;
export const selectLocalMediaConstraintsOptions = createSelector(selectLocalMediaDevices, (devices) => ({
devices,
options: {
Expand Down
1 change: 1 addition & 0 deletions src/lib/core/redux/tests/store/localMedia.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ describe("actions", () => {
microphoneEnabled: true,
status: "started",
stream: new MockMediaStream([audioTrack, videoTrack]),
isSwitchingStream: false,
},
};
});
Expand Down
4 changes: 4 additions & 0 deletions src/lib/react/useLocalMedia/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
doStopLocalMedia,
doToggleCameraEnabled,
doToggleMicrophoneEnabled,
doUpdateDeviceList,
} from "../../core/redux/slices/localMedia";
import { LocalMediaState, UseLocalMediaOptions, UseLocalMediaResult } from "./types";
import { selectLocalMediaState } from "./selector";
import { createStore, observeStore, Store } from "../../core/redux/store";
import { createServices } from "../../services";
import debounce from "../../utils/debounce";

const initialState: LocalMediaState = {
cameraDeviceError: null,
Expand All @@ -32,9 +34,11 @@ export function useLocalMedia(
return createStore({ injectServices: services });
});
const [localMediaState, setLocalMediaState] = useState(initialState);

useEffect(() => {
const unsubscribe = observeStore(store, selectLocalMediaState, setLocalMediaState);
store.dispatch(doStartLocalMedia(optionsOrStream));

return () => {
unsubscribe();
store.dispatch(doStopLocalMedia());
Expand Down
54 changes: 54 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,4 +343,58 @@ declare module "@whereby/jslib-media/src/webrtc/MediaDevices" {
constraintOpt: GetConstraintsOptions,
getStreamOptions?: GetStreamOptions
): Promise<GetStreamResult>;

export function enumerate(): Promise<MediaDeviceInfo[]>;

export function getUpdatedDevices({
oldDevices,
newDevices,
currentAudioId,
currentVideoId,
currentSpeakerId,
}: {
oldDevices: MediaDeviceInfo[];
newDevices: MediaDeviceInfo[];
currentAudioId?: string | undefined;
currentVideoId?: string | undefined;
currentSpeakerId?: string | undefined;
}): {
addedDevices: {
audioinput?: { deviceId: string; label: string; kind: string };
videoinput?: { deviceId: string; label: string; kind: string };
audiooutput?: { deviceId: string; label: string; kind: string };
};
changedDevices: {
audioinput?: { deviceId: string; label: string; kind: string };
videoinput?: { deviceId: string; label: string; kind: string };
audiooutput?: { deviceId: string; label: string; kind: string };
};
};

export function getDeviceData({
audioTrack,
videoTrack,
devices,
stoppedVideoTrack,
lastAudioId,
lastVideoId,
}: {
audioTrack?: MediaStreamTrack | null;
videoTrack?: MediaStreamTrack | null;
devices: MediaDeviceInfo[];
stoppedVideoTrack?: boolean;
lastAudioId?: string | undefined;
lastVideoId?: string | undefined;
}): {
audio: {
deviceId: string;
label: string;
kind: string;
};
video: {
deviceId: string;
label: string;
kind: string;
};
};
}

0 comments on commit 261509c

Please sign in to comment.