Skip to content
This repository has been archived by the owner on Jan 30, 2024. It is now read-only.

Commit

Permalink
Feature/mv 44 ability to choose camera and mic (#161)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Szymon Kania <[email protected]>
Co-authored-by: Paweł Kruczkiewicz <[email protected]>
Co-authored-by: Bartosz Błaszków <[email protected]>
  • Loading branch information
4 people authored May 11, 2023
1 parent 73ca566 commit 14c2d90
Show file tree
Hide file tree
Showing 46 changed files with 2,342 additions and 744 deletions.
844 changes: 692 additions & 152 deletions assets/package-lock.json

Large diffs are not rendered by default.

14 changes: 9 additions & 5 deletions assets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"typing:check": "tsc --noEmit --skipLibCheck"
},
"dependencies": {
"@jellyfish-dev/membrane-webrtc-js": "^0.4.5",
"@jellyfish-dev/membrane-webrtc-js": "^0.4.6",
"chartist": "^1.3.0",
"clsx": "^1.2.1",
"date-fns": "^2.29.3",
Expand All @@ -19,9 +19,11 @@
"ramda": "^0.29.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-modal": "^3.16.1",
"react-page-visibility": "^7.0.0",
"react-resize-detector": "^8.0.4",
"react-router-dom": "^6.4.2",
"react-select": "^5.7.0",
"uuid": "^8.3.2"
},
"devDependencies": {
Expand All @@ -30,19 +32,21 @@
"@types/ramda": "^0.29.0",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"@types/react-modal": "^3.16.0",
"@types/react-page-visibility": "^6.4.1",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.40.1",
"@typescript-eslint/parser": "^5.40.1",
"@typescript-eslint/eslint-plugin": "^5.59.1",
"@typescript-eslint/parser": "^5.59.1",
"autoprefixer": "^10.4.13",
"eslint": "^8.25.0",
"eslint": "^8.39.0",
"eslint-plugin-react": "^7.31.10",
"eslint-plugin-react-hooks": "^4.6.0",
"playwright": "1.17",
"postcss": "^8.4.21",
"postcss-import": "^14.0.2",
"prettier": "^2.8.3",
"prettier-plugin-tailwindcss": "^0.2.1",
"tailwindcss": "^3.2.7"
"tailwindcss": "^3.2.7",
"typescript": "^5.0.4"
}
}
22 changes: 18 additions & 4 deletions assets/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,32 @@ import { DeveloperInfoProvider } from "./contexts/DeveloperInfoContext";
import { router } from "./Routes";
import { UserProvider } from "./contexts/UserContext";
import { ToastProvider } from "./features/shared/context/ToastContext";
import { PreviewSettingsProvider } from "./features/home-page/context/PreviewSettingsContext";
import { ModalProvider } from "./contexts/ModalContext";
import { DeviceErrorBoundary } from "./features/devices/DeviceErrorBoundary";
import { LocalPeerMediaProvider } from "./features/devices/LocalPeerMediaContext";
import { MediaSettingsModal } from "./features/devices/MediaSettingsModal";
import { disableSafariCache } from "./features/devices/disableSafariCache";

// When returning to the videoroom page from another domain using the 'Back' button on the Safari browser,
// the page is served from the cache, which prevents lifecycle events from being triggered.
// As a result, the camera and microphone do not start. To resolve this issue, one simple solution is to disable the cache.
disableSafariCache();

const App: FC = () => {
return (
<React.StrictMode>
<UserProvider>
<DeveloperInfoProvider>
<PreviewSettingsProvider>
<LocalPeerMediaProvider>
<ToastProvider>
<RouterProvider router={router} />
<ModalProvider>
<DeviceErrorBoundary>
<RouterProvider router={router} />
<MediaSettingsModal />
</DeviceErrorBoundary>
</ModalProvider>
</ToastProvider>
</PreviewSettingsProvider>
</LocalPeerMediaProvider>
</DeveloperInfoProvider>
</UserProvider>
</React.StrictMode>
Expand Down
11 changes: 1 addition & 10 deletions assets/src/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { useDeveloperInfo } from "./contexts/DeveloperInfoContext";
import { useUser } from "./contexts/UserContext";
import VideoroomHomePage from "./features/home-page/components/VideoroomHomePage";
import LeavingRoomScreen from "./features/home-page/components/LeavingRoomScreen";
import { usePreviewSettings } from "./features/home-page/hooks/usePreviewSettings";
import Page404 from "./features/shared/components/Page404";
import { WebrtcInternalsPage } from "./pages/webrtcInternals/WebrtcInternalsPage";

Expand All @@ -16,21 +15,13 @@ const RoomPageWrapper: React.FC = () => {
const isLeavingRoom = !!state?.isLeavingRoom;
const { username } = useUser();
const { simulcast, manualMode } = useDeveloperInfo();
const { cameraAutostart, audioAutostart } = usePreviewSettings();

if (isLeavingRoom && roomId) {
return <LeavingRoomScreen roomId={roomId} />;
}

return username && roomId ? (
<RoomPage
displayName={username}
roomId={roomId}
isSimulcastOn={simulcast.status}
manualMode={manualMode.status}
cameraAutostartStreaming={cameraAutostart.status}
audioAutostartStreaming={audioAutostart.status}
/>
<RoomPage displayName={username} roomId={roomId} isSimulcastOn={simulcast.status} manualMode={manualMode.status} />
) : (
<VideoroomHomePage />
);
Expand Down
26 changes: 26 additions & 0 deletions assets/src/contexts/ModalContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, { useContext, useState } from "react";

export type ModalContextType = {
setOpen: (value: boolean) => void;
isOpen: boolean;
};

const ModelContext = React.createContext<ModalContextType | undefined>(undefined);

type Props = {
children: React.ReactNode;
};

export const ModalProvider = ({ children }: Props) => {
const [isOpen, setIsOpen] = useState<boolean>(false);

return (
<ModelContext.Provider value={{ setOpen: (value) => setIsOpen(value), isOpen }}>{children}</ModelContext.Provider>
);
};

export const useModal = (): ModalContextType => {
const context = useContext(ModelContext);
if (!context) throw new Error("useModal must be used within a ModalProvider");
return context;
};
38 changes: 38 additions & 0 deletions assets/src/features/devices/DeviceErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React, { FC, PropsWithChildren } from "react";
import useToast from "../shared/hooks/useToast";
import useEffectOnChange from "../shared/hooks/useEffectOnChange";
import { useLocalPeer } from "./LocalPeerMediaContext";

const prepareErrorMessage = (videoDeviceError: string | null, audioDeviceError: string | null): null | string => {
if (videoDeviceError && audioDeviceError) {
return "Access to camera and microphone is blocked";
} else if (videoDeviceError) {
return "Access to camera is blocked";
} else if (audioDeviceError) {
return "Access to microphone is blocked";
} else return null;
};

export const DeviceErrorBoundary: FC<PropsWithChildren> = ({ children }) => {
const { addToast } = useToast();
const { video, audio } = useLocalPeer();

useEffectOnChange(
[video.error, audio.error],
() => {
const message = prepareErrorMessage(video.error, audio.error);

if (message) {
addToast({
id: "device-not-allowed-error",
message: message,
timeout: "INFINITY",
type: "error",
});
}
},
(next, prev) => prev?.[0] === next[0] && prev?.[1] === next[1]
);

return <>{children}</>;
};
30 changes: 30 additions & 0 deletions assets/src/features/devices/DeviceSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from "react";
import { SelectOption } from "../shared/components/Select";
import Input from "../shared/components/Input";

type Props = {
name: string;
devices: MediaDeviceInfo[] | null;
setInput: (value: string | null) => void;
inputValue: string | null;
};

export const DeviceSelector = ({ name, devices, setInput, inputValue }: Props) => {
const options: SelectOption[] = (devices || []).map(({ deviceId, label }) => ({
value: deviceId,
label,
}));

return (
<Input
wrapperClassName="mt-14"
label={name}
type="select"
options={options}
onChange={(option) => {
setInput(option.value);
}}
value={options.find(({ value }) => value === inputValue)}
/>
);
};
145 changes: 145 additions & 0 deletions assets/src/features/devices/LocalPeerMediaContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import React, { useContext, useMemo, useState } from "react";
import { AUDIO_TRACK_CONSTRAINTS, VIDEO_TRACK_CONSTRAINTS } from "../../pages/room/consts";
import { loadObject, saveObject } from "../shared/utils/localStorage";
import { useMedia } from "./useMedia";
import { DeviceState, Type, UseUserMediaConfig, UseUserMediaStartConfig } from "./use-user-media/type";
import { useUserMedia } from "./use-user-media/useUserMedia";

export type Device = {
stream: MediaStream | null;
start: () => void;
stop: () => void;
isEnabled: boolean;
disable: () => void;
enable: () => void;
};

export type UserMedia = {
id: string | null;
setId: (id: string) => void;
device: Device;
error: string | null;
devices: MediaDeviceInfo[] | null;
};

export type DisplayMedia = {
setConfig: (constraints: MediaStreamConstraints | null) => void;
config: MediaStreamConstraints | null;
device: Device;
};

export type LocalPeerContextType = {
video: UserMedia;
audio: UserMedia;
screenShare: DisplayMedia;
start: (config: UseUserMediaStartConfig) => void;
};

const LocalPeerMediaContext = React.createContext<LocalPeerContextType | undefined>(undefined);

type Props = {
children: React.ReactNode;
};

const LOCAL_STORAGE_VIDEO_DEVICE_KEY = "last-selected-video-device";
const LOCAL_STORAGE_AUDIO_DEVICE_KEY = "last-selected-audio-device";

const useDisplayMedia = (screenSharingConfig: MediaStreamConstraints | null) =>
useMedia(
useMemo(
() => (screenSharingConfig ? () => navigator.mediaDevices.getDisplayMedia(screenSharingConfig) : null),
[screenSharingConfig]
)
);

const USE_USER_MEDIA_CONFIG: UseUserMediaConfig = {
getLastAudioDevice: () => loadObject<MediaDeviceInfo | null>(LOCAL_STORAGE_AUDIO_DEVICE_KEY, null),
saveLastAudioDevice: (info: MediaDeviceInfo) => saveObject<MediaDeviceInfo>(LOCAL_STORAGE_AUDIO_DEVICE_KEY, info),
getLastVideoDevice: () => loadObject<MediaDeviceInfo | null>(LOCAL_STORAGE_VIDEO_DEVICE_KEY, null),
saveLastVideoDevice: (info: MediaDeviceInfo) => saveObject<MediaDeviceInfo>(LOCAL_STORAGE_VIDEO_DEVICE_KEY, info),
videoTrackConstraints: VIDEO_TRACK_CONSTRAINTS,
audioTrackConstraints: AUDIO_TRACK_CONSTRAINTS,
refetchOnMount: true,
};

const useMediaData = (
data: DeviceState | null,
type: Type,
localStorageKey: string,
start: (config: UseUserMediaStartConfig) => void,
stop: (type: Type) => void,
setEnable: (type: Type, value: boolean) => void
) => {
const deviceIdKey: keyof UseUserMediaStartConfig = type === "video" ? "videoDeviceId" : "audioDeviceId";

return useMemo(
(): UserMedia => ({
id: data?.media?.deviceInfo?.deviceId || null,
setId: (value: string) => start({ [deviceIdKey]: value }),
device: {
stream: data?.media?.stream || null,
stop: () => stop(type),
start: () => start({ [deviceIdKey]: loadObject<MediaDeviceInfo | null>(localStorageKey, null)?.deviceId }),
disable: () => setEnable(type, false),
enable: () => setEnable(type, true),
isEnabled: !!data?.media?.enabled,
},
devices: data?.devices || null,
error: data?.error?.name || null,
}),
[data, stop, start, setEnable, type, localStorageKey, deviceIdKey]
);
};

export const LocalPeerMediaProvider = ({ children }: Props) => {
const { data, stop, start, setEnable } = useUserMedia(USE_USER_MEDIA_CONFIG);

const [screenSharingConfig, setScreenSharingConfig] = useState<MediaStreamConstraints | null>(null);
const screenSharingDevice: Device = useDisplayMedia(screenSharingConfig);

const video: UserMedia = useMediaData(
data?.video || null,
"video",
LOCAL_STORAGE_VIDEO_DEVICE_KEY,
start,
stop,
setEnable
);

const audio: UserMedia = useMediaData(
data?.audio || null,
"audio",
LOCAL_STORAGE_AUDIO_DEVICE_KEY,
start,
stop,
setEnable
);

const screenShare: DisplayMedia = useMemo(
() => ({
config: screenSharingConfig,
setConfig: setScreenSharingConfig,
device: screenSharingDevice,
}),
[screenSharingConfig, screenSharingDevice]
);

return (
<LocalPeerMediaContext.Provider
value={{
video,
audio,
screenShare,
start,
}}
>
{children}
</LocalPeerMediaContext.Provider>
);
};

export const useLocalPeer = (): LocalPeerContextType => {
const context = useContext(LocalPeerMediaContext);
if (!context) throw new Error("useLocalPeer must be used within a LocalPeerMediaContext");
return context;
};
Loading

0 comments on commit 14c2d90

Please sign in to comment.