From 8c807be072a27a664bf4c2a8803b74035beaa21b Mon Sep 17 00:00:00 2001 From: Lucas Maupin Date: Thu, 9 Jan 2025 10:11:32 +0100 Subject: [PATCH] feat: device handling & user settings (#258) --- src/App.tsx | 4 +- .../access-local-storage.ts | 25 ++-- src/components/calls-page/calls-page.tsx | 16 +- .../landing-page/join-production.tsx | 141 +++++------------- src/components/landing-page/landing-page.tsx | 11 +- .../landing-page/user-settings-button.tsx | 4 +- src/components/loader/loader.tsx | 14 +- src/components/modal/modal.tsx | 4 +- .../production-line/production-line.tsx | 37 +---- src/components/production-line/types.ts | 4 +- .../production-list/production-list-item.tsx | 6 +- .../reload-devices-button.tsx | 80 ++++++---- src/components/user-settings/types.ts | 2 +- .../user-settings/user-settings.tsx | 63 ++++---- src/global-state/global-state-actions.ts | 4 +- src/global-state/global-state-reducer.ts | 5 +- src/global-state/types.ts | 8 +- src/hooks/use-fetch-devices.ts | 36 +++-- src/hooks/use-local-user-settings.ts | 50 +++++-- 19 files changed, 251 insertions(+), 263 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 51008142..644f71a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,7 +51,7 @@ const App = () => { const continueToApp = isValidBrowser || unsupportedContinue; const { denied, permission } = useDevicePermissions({ continueToApp }); const initializedGlobalState = useInitializeGlobalStateReducer(); - const [, dispatch] = initializedGlobalState; + const [{ devices }, dispatch] = initializedGlobalState; const [apiError, setApiError] = useState(false); useFetchDevices({ @@ -59,7 +59,7 @@ const App = () => { permission, }); - useLocalUserSettings({ dispatch }); + useLocalUserSettings({ devices, dispatch }); return ( diff --git a/src/components/accessing-local-storage/access-local-storage.ts b/src/components/accessing-local-storage/access-local-storage.ts index 82da9d73..0be06b5f 100644 --- a/src/components/accessing-local-storage/access-local-storage.ts +++ b/src/components/accessing-local-storage/access-local-storage.ts @@ -1,8 +1,9 @@ -import { useCallback } from "react"; import { createStorage, StorageType } from "@martinstark/storage-ts"; type Schema = { username: string; + audioinput?: string; + audiooutput?: string; }; // Create a store of the desired type. If it is not available, @@ -13,17 +14,19 @@ const store = createStorage({ silent: true, }); -export function useStorage(key: Key) { - const readFromStorage = useCallback((): Schema[Key] | null => { +export function useStorage() { + type Key = keyof Schema; + const readFromStorage = (key: keyof Schema): Schema[Key] | null => { return store.read(key); - }, [key]); + }; - const writeToStorage = useCallback( - (value: Schema[Key]): void => { - store.write(key, value); - }, - [key] - ); + const writeToStorage = (key: keyof Schema, value: Schema[Key]): void => { + store.write(key, value); + }; - return { readFromStorage, writeToStorage }; + const clearStorage = (key: keyof Schema) => { + store.delete(key); + }; + + return { readFromStorage, writeToStorage, clearStorage }; } diff --git a/src/components/calls-page/calls-page.tsx b/src/components/calls-page/calls-page.tsx index 072fa0aa..1b6dc4f6 100644 --- a/src/components/calls-page/calls-page.tsx +++ b/src/components/calls-page/calls-page.tsx @@ -188,15 +188,13 @@ export const CallsPage = () => { )} {isEmpty && paramProductionId && paramLineId && ( - - - + )} diff --git a/src/components/landing-page/join-production.tsx b/src/components/landing-page/join-production.tsx index ea897559..204e74a9 100644 --- a/src/components/landing-page/join-production.tsx +++ b/src/components/landing-page/join-production.tsx @@ -6,7 +6,6 @@ import { DisplayContainerHeader } from "./display-container-header.tsx"; import { DecorativeLabel, FormLabel, - FormContainer, FormInput, FormSelect, PrimaryButton, @@ -16,14 +15,15 @@ import { useGlobalState } from "../../global-state/context-provider.tsx"; import { useFetchProduction } from "./use-fetch-production.ts"; import { darkText, errorColour } from "../../css-helpers/defaults.ts"; import { TJoinProductionOptions } from "../production-line/types.ts"; -import { uniqBy } from "../../helpers.ts"; import { FormInputWithLoader } from "./form-input-with-loader.tsx"; import { useStorage } from "../accessing-local-storage/access-local-storage.ts"; import { useNavigateToProduction } from "./use-navigate-to-production.ts"; -import { Modal } from "../modal/modal.tsx"; import { ReloadDevicesButton } from "../reload-devices-button.tsx/reload-devices-button.tsx"; -import { useDevicePermissions } from "../../hooks/use-device-permission.ts"; -import { useFetchDevices } from "../../hooks/use-fetch-devices.ts"; +import { + ButtonWrapper, + ResponsiveFormContainer, +} from "../user-settings/user-settings.tsx"; +import { isMobile } from "../../bowser.ts"; type FormValues = TJoinProductionOptions; @@ -35,15 +35,6 @@ const FetchErrorMessage = styled.div` border-radius: 0.5rem; `; -const ButtonWrapper = styled.div` - margin: 2rem 0 2rem 0; -`; - -const FormWithBtn = styled.div` - display: flex; - justify-content: space-between; -`; - type TProps = { preSelected?: { preSelectedProductionId: string; @@ -63,9 +54,7 @@ export const JoinProduction = ({ const [joinProductionId, setJoinProductionId] = useState(null); const [joinProductionOptions, setJoinProductionOptions] = useState(null); - const { readFromStorage, writeToStorage } = useStorage("username"); - const [refresh, setRefresh] = useState(0); - const [firefoxWarningModalOpen, setFirefoxWarningModalOpen] = useState(false); + const { readFromStorage, writeToStorage } = useStorage(); const { formState: { errors, isValid }, @@ -78,25 +67,16 @@ export const JoinProduction = ({ productionId: preSelected?.preSelectedProductionId || addAdditionalCallId || "", lineId: preSelected?.preSelectedLineId || undefined, - username: readFromStorage() || "", + username: readFromStorage("username") || "", }, resetOptions: { keepDirtyValues: true, // user-interacted input will be retained keepErrors: true, // input errors will be retained with value update }, }); - const { permission } = useDevicePermissions({ - continueToApp: true, - }); const [{ devices, selectedProductionId }, dispatch] = useGlobalState(); - useFetchDevices({ - dispatch, - permission, - refresh, - }); - const { error: productionFetchError, production, @@ -127,7 +107,7 @@ export const JoinProduction = ({ // Use local cache useEffect(() => { - const cachedUsername = readFromStorage(); + const cachedUsername = readFromStorage("username"); if (cachedUsername) { setValue("username", cachedUsername); } @@ -154,7 +134,7 @@ export const JoinProduction = ({ const onSubmit: SubmitHandler = (payload) => { if (payload.username) { - writeToStorage(payload.username); + writeToStorage("username", payload.username); } if (closeAddCallView) { @@ -173,7 +153,6 @@ export const JoinProduction = ({ payload: { id: uuid, callState: { - devices: null, joinProductionOptions: payload, mediaStreamInput: null, dominantSpeaker: null, @@ -197,22 +176,8 @@ export const JoinProduction = ({ console.log("PAYLOAD: ", payload); }; - const outputDevices = devices - ? uniqBy( - devices.filter((d) => d.kind === "audiooutput"), - (item) => item.deviceId - ) - : []; - - const inputDevices = devices - ? uniqBy( - devices.filter((d) => d.kind === "audioinput"), - (item) => item.deviceId - ) - : []; - return ( - + Join Production {devices && ( <> @@ -265,65 +230,38 @@ export const JoinProduction = ({ /> Input - + + {devices.input && devices.input.length > 0 ? ( + devices.input.map((device) => ( + + )) + ) : ( + + )} + + + + Output + {devices.output && devices.output.length > 0 ? ( - {inputDevices.length > 0 ? ( - inputDevices.map((device) => ( - - )) - ) : ( - - )} + {devices.output.map((device) => ( + + ))} - setRefresh((prev) => prev + 1)} - devices={devices} - isDummy - /> - - - - Output - - {outputDevices.length > 0 ? ( - - {outputDevices.map((device) => ( - - ))} - - ) : ( - - Controlled by operating system - - )} - setRefresh((prev) => prev + 1)} - setFirefoxWarningModalOpen={() => - setFirefoxWarningModalOpen(true) - } - devices={devices} - /> - - {firefoxWarningModalOpen && ( - setFirefoxWarningModalOpen(false)}> - - Reset permissions - -

- To reload devices Firefox needs the permission to be manually - reset, please remove permission and reload page instead. -

-
+ ) : ( + + Controlled by operating system + )}
{!preSelected && ( @@ -355,6 +293,7 @@ export const JoinProduction = ({ )} + )} -
+ ); }; diff --git a/src/components/landing-page/landing-page.tsx b/src/components/landing-page/landing-page.tsx index 2248c08e..eca70c89 100644 --- a/src/components/landing-page/landing-page.tsx +++ b/src/components/landing-page/landing-page.tsx @@ -3,9 +3,10 @@ import { ProductionsListContainer } from "./productions-list-container.tsx"; import { useGlobalState } from "../../global-state/context-provider.tsx"; import { UserSettings } from "../user-settings/user-settings.tsx"; import { UserSettingsButton } from "./user-settings-button.tsx"; +import { TUserSettings } from "../user-settings/types.ts"; export const LandingPage = ({ setApiError }: { setApiError: () => void }) => { - const [{ apiError }] = useGlobalState(); + const [{ apiError, userSettings }] = useGlobalState(); const [showSettings, setShowSettings] = useState(false); useEffect(() => { @@ -14,9 +15,15 @@ export const LandingPage = ({ setApiError }: { setApiError: () => void }) => { } }, [apiError, setApiError]); + if (!userSettings) return
; + + const isUserSettingsComplete = (settings: TUserSettings) => { + return settings.username && (settings.audioinput || settings.audiooutput); + }; + return (
- {((showSettings || !window.localStorage?.getItem("username")) && ( + {((showSettings || !isUserSettingsComplete(userSettings)) && ( setShowSettings(false)} diff --git a/src/components/landing-page/user-settings-button.tsx b/src/components/landing-page/user-settings-button.tsx index 739946e3..3e71164f 100644 --- a/src/components/landing-page/user-settings-button.tsx +++ b/src/components/landing-page/user-settings-button.tsx @@ -1,6 +1,7 @@ import styled from "@emotion/styled"; import { FC } from "react"; import { UserSettingsIcon } from "../../assets/icons/icon"; +import { useStorage } from "../accessing-local-storage/access-local-storage"; const UserSettingsWrapper = styled.div` position: absolute; @@ -26,10 +27,11 @@ interface UserSettingsButtonProps { export const UserSettingsButton: FC = (props) => { const { onClick } = props; + const { readFromStorage } = useStorage(); return ( - {window.localStorage?.getItem("username") || "Guest"} + {readFromStorage("username") || "Guest"} ); diff --git a/src/components/loader/loader.tsx b/src/components/loader/loader.tsx index 8ba7f2a0..619fa531 100644 --- a/src/components/loader/loader.tsx +++ b/src/components/loader/loader.tsx @@ -2,8 +2,8 @@ import styled from "@emotion/styled"; import { FC, useEffect, useState } from "react"; const Loading = styled.div` - border: 0.4rem solid rgba(0, 0, 0, 0.1); - border-top: 0.4rem solid #333; + border: 0.3rem solid rgba(0, 0, 0, 0.1); + border-top: 0.3rem solid #333; border-radius: 50%; width: 3rem; height: 3rem; @@ -22,16 +22,6 @@ const Loading = styled.div` left: 30%; } - &.refresh-devices { - position: absolute; - top: 0.5rem; - left: 0.5rem; - padding: 0; - margin: 0; - width: 2.5rem; - height: 2.5rem; - } - &.join-production { border: 0.4rem solid rgba(201, 201, 201, 0.1); border-top: 0.4rem solid #e2e2e2; diff --git a/src/components/modal/modal.tsx b/src/components/modal/modal.tsx index 0586c01d..8855ef30 100644 --- a/src/components/modal/modal.tsx +++ b/src/components/modal/modal.tsx @@ -4,8 +4,8 @@ const ModalWrapper = styled.div` position: fixed; top: 0; left: 0; - width: 100%; - height: 100%; + width: 100vw; + height: 100vh; background-color: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; diff --git a/src/components/production-line/production-line.tsx b/src/components/production-line/production-line.tsx index 2e2c0d34..31bc667d 100644 --- a/src/components/production-line/production-line.tsx +++ b/src/components/production-line/production-line.tsx @@ -21,7 +21,6 @@ import { PrimaryButton, StyledWarningMessage, } from "../landing-page/form-elements.tsx"; -import { uniqBy } from "../../helpers.ts"; import { Spinner } from "../loader/loader.tsx"; import { DisplayContainerHeader } from "../landing-page/display-container-header.tsx"; import { DisplayContainer, FlexContainer } from "../generic-components.ts"; @@ -160,7 +159,6 @@ export const ProductionLine = ({ const [isInputMuted, setIsInputMuted] = useState(true); const [isOutputMuted, setIsOutputMuted] = useState(false); const [showDeviceSettings, setShowDeviceSettings] = useState(false); - const [refresh, setRefresh] = useState(0); const [confirmExitModalOpen, setConfirmExitModalOpen] = useState(false); const [value, setValue] = useState(0.75); const { @@ -276,10 +274,9 @@ export const ProductionLine = ({ customKeyPress: savedHotkeys?.pushToTalkHotkey || "t", }); - useFetchDevices({ + const [refresh] = useFetchDevices({ dispatch, permission: true, - refresh, }); useEffect(() => { @@ -354,22 +351,8 @@ export const ProductionLine = ({ dispatch, }); - const outputDevices = devices - ? uniqBy( - devices.filter((d) => d.kind === "audiooutput"), - (item) => item.deviceId - ) - : []; - - const inputDevices = devices - ? uniqBy( - devices.filter((d) => d.kind === "audioinput"), - (item) => item.deviceId - ) - : []; - const settingsButtonPressed = () => { - setRefresh((prev) => prev + 1); + refresh(); setShowDeviceSettings(!showDeviceSettings); }; @@ -392,7 +375,6 @@ export const ProductionLine = ({ payload: { id, updates: { - devices: null, joinProductionOptions: newJoinProductionOptions, mediaStreamInput: null, dominantSpeaker: null, @@ -552,8 +534,8 @@ export const ProductionLine = ({ // eslint-disable-next-line {...register(`audioinput`)} > - {inputDevices.length > 0 ? ( - inputDevices.map((device) => ( + {devices.input && devices.input.length > 0 ? ( + devices.input.map((device) => ( @@ -565,12 +547,12 @@ export const ProductionLine = ({ Output - {outputDevices.length > 0 ? ( + {devices.output && devices.output.length > 0 ? ( - {outputDevices.map((device) => ( + {devices.output.map((device) => ( @@ -598,12 +580,7 @@ export const ProductionLine = ({ Save {!(isBrowserFirefox && !isMobile) && ( - - setRefresh((prev) => prev + 1) - } - devices={devices} - /> + )} diff --git a/src/components/production-line/types.ts b/src/components/production-line/types.ts index 439e6d56..ab5e673d 100644 --- a/src/components/production-line/types.ts +++ b/src/components/production-line/types.ts @@ -4,9 +4,9 @@ export type TJoinProductionOptions = { lineId: string; username: string; // Not all devices have input available - audioinput: string | "no-device"; + audioinput?: string; // Not all devices allow choosing output - audiooutput: string | null; + audiooutput?: string; }; export type Hotkeys = { diff --git a/src/components/production-list/production-list-item.tsx b/src/components/production-list/production-list-item.tsx index c4949dad..bd83ea95 100644 --- a/src/components/production-list/production-list-item.tsx +++ b/src/components/production-list/production-list-item.tsx @@ -139,14 +139,13 @@ export const ProductionsListItem = ({ }, [production]); const goToProduction = (lineId: string) => { - // TODO add some visual feedback here if somehow userSettings is not configured if (userSettings?.username) { const payload = { productionId: production.productionId, lineId, username: userSettings.username, - audioinput: userSettings?.audioinput || "no-device", - audiooutput: userSettings?.audiooutput || null, + audioinput: userSettings?.audioinput, + audiooutput: userSettings?.audiooutput, }; const uuid = globalThis.crypto.randomUUID(); @@ -156,7 +155,6 @@ export const ProductionsListItem = ({ payload: { id: uuid, callState: { - devices: null, joinProductionOptions: payload, mediaStreamInput: null, dominantSpeaker: null, diff --git a/src/components/reload-devices-button.tsx/reload-devices-button.tsx b/src/components/reload-devices-button.tsx/reload-devices-button.tsx index 9e6d4c3a..5d04f92d 100644 --- a/src/components/reload-devices-button.tsx/reload-devices-button.tsx +++ b/src/components/reload-devices-button.tsx/reload-devices-button.tsx @@ -4,15 +4,22 @@ import { RefreshIcon } from "../../assets/icons/icon"; import { PrimaryButton } from "../landing-page/form-elements"; import { Spinner } from "../loader/loader"; import { isBrowserFirefox, isMobile } from "../../bowser"; +import { useGlobalState } from "../../global-state/context-provider"; +import { useFetchDevices } from "../../hooks/use-fetch-devices"; +import { useDevicePermissions } from "../../hooks/use-device-permission"; +import { Modal } from "../modal/modal"; +import { DisplayContainerHeader } from "../landing-page/display-container-header"; const StyledRefreshBtn = styled(PrimaryButton)` - padding: 0; - margin: 0; - width: 3.5rem; - height: 3.5rem; - margin-left: 1.5rem; - flex-shrink: 0; /* Prevent shrinking */ - flex-basis: auto; /* Prevent shrinking */ + margin-right: 1.5rem; + display: flex; + align-items: center; + + svg, + .refresh-devices { + width: 2rem; + height: 2rem; + } &.dummy { background-color: #242424; @@ -20,18 +27,19 @@ const StyledRefreshBtn = styled(PrimaryButton)` } `; -export const ReloadDevicesButton = ({ - handleReloadDevices, - setFirefoxWarningModalOpen, - devices, - isDummy, -}: { - handleReloadDevices: () => void; - setFirefoxWarningModalOpen?: () => void; - devices: MediaDeviceInfo[]; - isDummy?: boolean; -}) => { +export const ReloadDevicesButton = () => { + const [{ devices }, dispatch] = useGlobalState(); const [deviceRefresh, setDeviceRefresh] = useState(false); + const [firefoxWarningModalOpen, setFirefoxWarningModalOpen] = useState(false); + + const { permission } = useDevicePermissions({ + continueToApp: true, + }); + + const [refresh] = useFetchDevices({ + dispatch, + permission, + }); useEffect(() => { let timeout: number | null = null; @@ -48,24 +56,34 @@ export const ReloadDevicesButton = ({ }, [devices]); const reloadDevices = () => { - if (isBrowserFirefox && !isMobile && setFirefoxWarningModalOpen) { - setFirefoxWarningModalOpen(); + if (isBrowserFirefox && !isMobile) { + setFirefoxWarningModalOpen(true); } else { setDeviceRefresh(true); - handleReloadDevices(); + refresh(); } }; return ( - reloadDevices()} - > - {!deviceRefresh && } - {deviceRefresh && } - + <> + reloadDevices()} + > +
Refresh Devices
+ {!deviceRefresh && } + {deviceRefresh && } +
+ {firefoxWarningModalOpen && ( + setFirefoxWarningModalOpen(false)}> + Reset permissions +

+ To reload devices Firefox needs the permission to be manually reset, + please remove permission and reload page instead. +

+
+ )} + ); }; diff --git a/src/components/user-settings/types.ts b/src/components/user-settings/types.ts index d6047c79..0abf8990 100644 --- a/src/components/user-settings/types.ts +++ b/src/components/user-settings/types.ts @@ -1,7 +1,7 @@ export type TUserSettings = { username: string; // Not all devices have input available - audioinput?: string; + audioinput?: string | "no-device"; // Not all devices allow choosing output audiooutput?: string; }; diff --git a/src/components/user-settings/user-settings.tsx b/src/components/user-settings/user-settings.tsx index 3dbad12a..e096bdb5 100644 --- a/src/components/user-settings/user-settings.tsx +++ b/src/components/user-settings/user-settings.tsx @@ -1,9 +1,8 @@ -import { FC } from "react"; +import { FC, useEffect } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; import { ErrorMessage } from "@hookform/error-message"; import styled from "@emotion/styled"; import { useGlobalState } from "../../global-state/context-provider"; -import { uniqBy } from "../../helpers"; import { TUserSettings } from "./types"; import { DecorativeLabel, @@ -16,8 +15,8 @@ import { } from "../landing-page/form-elements"; import { DisplayContainerHeader } from "../landing-page/display-container-header"; import { isMobile } from "../../bowser"; - -type FormValues = TUserSettings; +import { ReloadDevicesButton } from "../reload-devices-button.tsx/reload-devices-button"; +import { useStorage } from "../accessing-local-storage/access-local-storage"; export const ResponsiveFormContainer = styled(FormContainer)` padding: 0 2rem; @@ -29,7 +28,7 @@ export const ResponsiveFormContainer = styled(FormContainer)` } `; -const ButtonWrapper = styled.div` +export const ButtonWrapper = styled.div` margin: 2rem 0 2rem 0; display: flex; justify-content: flex-end; @@ -42,14 +41,16 @@ interface UserSettingsProps { export const UserSettings: FC = (props) => { const { buttonText, onSave } = props; - const [{ devices, userSettings }, dispatch] = useGlobalState(); + const { writeToStorage } = useStorage(); const { formState: { errors }, register, + getValues, + setValue, handleSubmit, - } = useForm({ + } = useForm({ defaultValues: { username: userSettings?.username, audioinput: userSettings?.audioinput, @@ -61,17 +62,34 @@ export const UserSettings: FC = (props) => { }, }); - const onSubmit: SubmitHandler = (payload) => { + useEffect(() => { + if (!devices.input?.length) { + setValue("audioinput", "no-device"); + } else if ( + !devices.input?.find( + (device) => device.deviceId === getValues("audioinput") + ) + ) + setValue("audioinput", "default"); + if ( + !devices.output?.find( + (device) => device.deviceId === getValues("audiooutput") + ) + ) + setValue("audiooutput", "default"); + }, [devices, getValues, setValue]); + + const onSubmit: SubmitHandler = (payload) => { if (payload.username) { - window.localStorage?.setItem("username", payload.username); + writeToStorage("username", payload.username); } if (payload.audioinput) { - window.localStorage?.setItem("audioinput", payload.audioinput); + writeToStorage("audioinput", payload.audioinput); } if (payload.audiooutput) { - window.localStorage?.setItem("audiooutput", payload.audiooutput); + writeToStorage("audiooutput", payload.audiooutput); } dispatch({ @@ -81,20 +99,6 @@ export const UserSettings: FC = (props) => { if (onSave) onSave(); }; - const outputDevices = devices - ? uniqBy( - devices.filter((d) => d.kind === "audiooutput"), - (item) => item.deviceId - ) - : []; - - const inputDevices = devices - ? uniqBy( - devices.filter((d) => d.kind === "audioinput"), - (item) => item.deviceId - ) - : []; - return ( User Settings @@ -122,8 +126,8 @@ export const UserSettings: FC = (props) => { // eslint-disable-next-line {...register(`audioinput`)} > - {inputDevices.length > 0 ? ( - inputDevices.map((device) => ( + {devices.input && devices.input.length > 0 ? ( + devices.input.map((device) => ( @@ -135,12 +139,12 @@ export const UserSettings: FC = (props) => {
Output - {outputDevices.length > 0 ? ( + {devices.output && devices.output.length > 0 ? ( - {outputDevices.map((device) => ( + {devices.output.map((device) => ( @@ -153,6 +157,7 @@ export const UserSettings: FC = (props) => { )} + {buttonText || "Save"} diff --git a/src/global-state/global-state-actions.ts b/src/global-state/global-state-actions.ts index 2b81c5fa..068bf27f 100644 --- a/src/global-state/global-state-actions.ts +++ b/src/global-state/global-state-actions.ts @@ -1,4 +1,4 @@ -import { CallState } from "./types.ts"; +import { CallState, DevicesState } from "./types.ts"; import { TUserSettings } from "../components/user-settings/types.ts"; export type TGlobalStateAction = @@ -32,7 +32,7 @@ export type TProductionListFetched = { export type TUpdateDevicesAction = { type: "DEVICES_UPDATED"; - payload: MediaDeviceInfo[]; + payload: DevicesState; }; export type TSelectProductionId = { diff --git a/src/global-state/global-state-reducer.ts b/src/global-state/global-state-reducer.ts index c6c15685..5d4ffcbd 100644 --- a/src/global-state/global-state-reducer.ts +++ b/src/global-state/global-state-reducer.ts @@ -6,7 +6,10 @@ const initialGlobalState: TGlobalState = { production: null, error: { callErrors: null, globalError: null }, reloadProductionList: false, - devices: null, + devices: { + input: null, + output: null, + }, userSettings: null, selectedProductionId: null, calls: {}, diff --git a/src/global-state/types.ts b/src/global-state/types.ts index fd9d2ad4..56bbac5b 100644 --- a/src/global-state/types.ts +++ b/src/global-state/types.ts @@ -10,8 +10,12 @@ export interface ErrorState { callErrors?: Record | null; } +export interface DevicesState { + input: MediaDeviceInfo[] | null; + output: MediaDeviceInfo[] | null; +} + export interface CallState { - devices: MediaDeviceInfo[] | null; joinProductionOptions: TJoinProductionOptions | null; mediaStreamInput: MediaStream | null; dominantSpeaker: string | null; @@ -30,7 +34,7 @@ export type TGlobalState = { production: TProduction | null; error: ErrorState; reloadProductionList: boolean; - devices: MediaDeviceInfo[] | null; + devices: DevicesState; selectedProductionId: string | null; apiError: Error | false; }; diff --git a/src/hooks/use-fetch-devices.ts b/src/hooks/use-fetch-devices.ts index 12df61d8..525d102c 100644 --- a/src/hooks/use-fetch-devices.ts +++ b/src/hooks/use-fetch-devices.ts @@ -1,25 +1,41 @@ -import { Dispatch, useEffect } from "react"; +import { Dispatch, useEffect, useState } from "react"; import { TGlobalStateAction } from "../global-state/global-state-actions"; +import { uniqBy } from "../helpers"; type TUseFetchDevices = { permission: boolean; dispatch: Dispatch; - refresh?: number; }; -export const useFetchDevices = ({ - permission, - dispatch, - refresh, -}: TUseFetchDevices) => { +export const useFetchDevices = ({ permission, dispatch }: TUseFetchDevices) => { + const [refreshState, setRefreshState] = useState(false); + + const refresh = () => setRefreshState(!refreshState); + useEffect(() => { if (permission) { window.navigator.mediaDevices .enumerateDevices() .then((payload) => { + const outputDevices = payload + ? uniqBy( + payload.filter((d) => d.kind === "audiooutput"), + (item) => item.deviceId + ) + : []; + + const inputDevices = payload + ? uniqBy( + payload.filter((d) => d.kind === "audioinput"), + (item) => item.deviceId + ) + : []; dispatch({ type: "DEVICES_UPDATED", - payload, + payload: { + input: inputDevices, + output: outputDevices, + }, }); }) .catch((payload) => { @@ -31,5 +47,7 @@ export const useFetchDevices = ({ } return () => {}; - }, [dispatch, permission, refresh]); + }, [dispatch, permission, refreshState]); + + return [refresh]; }; diff --git a/src/hooks/use-local-user-settings.ts b/src/hooks/use-local-user-settings.ts index b1923c87..bc80fd67 100644 --- a/src/hooks/use-local-user-settings.ts +++ b/src/hooks/use-local-user-settings.ts @@ -1,21 +1,47 @@ import { Dispatch, useEffect } from "react"; +import { DevicesState } from "../global-state/types"; import { TGlobalStateAction } from "../global-state/global-state-actions"; +import { useStorage } from "../components/accessing-local-storage/access-local-storage"; type TUseLocalUserSettings = { + devices: DevicesState; dispatch: Dispatch; }; -export const useLocalUserSettings = ({ dispatch }: TUseLocalUserSettings) => { - // TODO check if device still exists +export const useLocalUserSettings = ({ + devices, + dispatch, +}: TUseLocalUserSettings) => { + const { readFromStorage, clearStorage } = useStorage(); useEffect(() => { - const payload = { - username: window.localStorage.getItem("username") || "", - audioinput: window.localStorage.getItem("audioinput") || undefined, - audiooutput: window.localStorage.getItem("audiooutput") || undefined, - }; - dispatch({ - type: "UPDATE_USER_SETTINGS", - payload, - }); - }, [dispatch]); + if (devices.input || devices.output) { + const storedAudioInput = readFromStorage("audioinput"); + const storedAudioOutput = readFromStorage("audiooutput"); + + const foundInputDevice = + devices.input?.find((device) => device.deviceId === storedAudioInput) + ?.deviceId ?? + (storedAudioInput === "no-device" ? "no-device" : undefined); + + const foundOutputDevice = devices.output?.find( + (device) => device.deviceId === storedAudioOutput + )?.deviceId; + + if (!foundInputDevice) clearStorage("audioinput"); + + if (!foundOutputDevice) clearStorage("audiooutput"); + + const payload = { + username: readFromStorage("username") || "", + audioinput: foundInputDevice, + audiooutput: foundOutputDevice, + }; + + dispatch({ + type: "UPDATE_USER_SETTINGS", + payload, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [devices, dispatch]); };