diff --git a/src/assets/icons/icon.tsx b/src/assets/icons/icon.tsx index 1f7e1127..a2675522 100644 --- a/src/assets/icons/icon.tsx +++ b/src/assets/icons/icon.tsx @@ -8,6 +8,7 @@ import UserSvg from "./user.svg?react"; import ConfirmSvg from "./done.svg?react"; import StepLeftSvg from "./chevron_left.svg?react"; import StepRightSvg from "./navigate_next.svg?react"; +import RefreshSvg from "./refresh.svg?react"; import Settings from "./settings.svg?react"; import ChevronDown from "./chevron_down.svg?react"; import ChevronUp from "./chevron_up.svg?react"; @@ -33,6 +34,8 @@ export const StepLeftIcon = () => ; export const StepRightIcon = () => ; +export const RefreshIcon = () => ; + export const SettingsIcon = () => ; export const ChevronDownIcon = () => ; diff --git a/src/assets/icons/refresh.svg b/src/assets/icons/refresh.svg new file mode 100644 index 00000000..563f9474 --- /dev/null +++ b/src/assets/icons/refresh.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/src/bowser.ts b/src/bowser.ts index 104c9a86..8b7a50f5 100644 --- a/src/bowser.ts +++ b/src/bowser.ts @@ -2,6 +2,7 @@ import Bowser from "bowser"; const deviceInfo = Bowser.parse(window.navigator.userAgent); const browser = Bowser.getParser(window.navigator.userAgent); +const browserName = browser.getBrowserName(); // platform type, can be either "desktop", "tablet" or "mobile" export const isMobile = deviceInfo.platform.type === "mobile"; @@ -13,3 +14,5 @@ export const isValidBrowser = browser.satisfies({ safari: ">=16.4", samsung: ">=21", }); + +export const isBrowserFirefox = browserName.toLowerCase() === "firefox"; diff --git a/src/components/landing-page/join-production.tsx b/src/components/landing-page/join-production.tsx index e4204875..e03077f2 100644 --- a/src/components/landing-page/join-production.tsx +++ b/src/components/landing-page/join-production.tsx @@ -20,6 +20,10 @@ 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 { useFetchDevices } from "../../use-fetch-devices.ts"; +import { useDevicePermissions } from "../../use-device-permission.ts"; +import { Modal } from "../modal/modal.tsx"; +import { ReloadDevicesButton } from "../reload-devices-button.tsx/reload-devices-button.tsx"; type FormValues = TJoinProductionOptions; @@ -35,6 +39,11 @@ const ButtonWrapper = styled.div` margin: 2rem 0 2rem 0; `; +const FormWithBtn = styled.div` + display: flex; + justify-content: space-between; +`; + type TProps = { preSelected?: { preSelectedProductionId: string; @@ -53,6 +62,9 @@ export const JoinProduction = ({ const [joinProductionOptions, setJoinProductionOptions] = useState(null); const { readFromStorage, writeToStorage } = useStorage("username"); + const [refresh, setRefresh] = useState(0); + const [firefoxWarningModalOpen, setFirefoxWarningModalOpen] = useState(false); + const { formState: { errors, isValid }, register, @@ -71,9 +83,18 @@ export const JoinProduction = ({ 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, @@ -150,8 +171,6 @@ export const JoinProduction = ({ payload: { id: uuid, callState: { - production: null, - reloadProductionList: false, devices: null, joinProductionOptions: payload, mediaStreamInput: null, @@ -236,38 +255,65 @@ export const JoinProduction = ({ /> Input - - {inputDevices.length > 0 ? ( - inputDevices.map((device) => ( - - {device.label} - - )) - ) : ( - No device available - )} - - - - Output - {outputDevices.length > 0 ? ( + - {outputDevices.map((device) => ( - - {device.label} - - ))} + {inputDevices.length > 0 ? ( + inputDevices.map((device) => ( + + {device.label} + + )) + ) : ( + No device available + )} - ) : ( - - Controlled by operating system - + setRefresh((prev) => prev + 1)} + devices={devices} + isDummy + /> + + + + Output + + {outputDevices.length > 0 ? ( + + {outputDevices.map((device) => ( + + {device.label} + + ))} + + ) : ( + + 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. + + )} {!preSelected && ( diff --git a/src/components/loader/loader.tsx b/src/components/loader/loader.tsx index 69a9b120..7a92a7e1 100644 --- a/src/components/loader/loader.tsx +++ b/src/components/loader/loader.tsx @@ -22,6 +22,16 @@ 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/production-line/production-line.tsx b/src/components/production-line/production-line.tsx index afbb2540..78a4ea42 100644 --- a/src/components/production-line/production-line.tsx +++ b/src/components/production-line/production-line.tsx @@ -1,9 +1,9 @@ import styled from "@emotion/styled"; import { useCallback, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; +import { SubmitHandler, useForm } from "react-hook-form"; import { useGlobalState } from "../../global-state/context-provider.tsx"; import { useAudioInput } from "./use-audio-input.ts"; -import { ActionButton } from "../landing-page/form-elements.tsx"; import { UserList } from "./user-list.tsx"; import { MicMuted, @@ -12,11 +12,21 @@ import { SpeakerOn, SettingsIcon, } from "../../assets/icons/icon.tsx"; +import { + ActionButton, + DecorativeLabel, + FormLabel, + FormContainer, + FormSelect, + 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"; import { useDeviceLabels } from "./use-device-labels.ts"; -import { isMobile } from "../../bowser.ts"; +import { isBrowserFirefox, isMobile } from "../../bowser.ts"; import { useLineHotkeys, useSpeakerHotkeys } from "./use-line-hotkeys.ts"; import { LongPressToTalkButton } from "./long-press-to-talk-button.tsx"; import { useLinePolling } from "./use-line-polling.ts"; @@ -25,6 +35,8 @@ import { useIsLoading } from "./use-is-loading.ts"; import { useCheckBadLineData } from "./use-check-bad-line-data.ts"; import { useAudioCue } from "./use-audio-cue.ts"; import { DisplayWarning } from "../display-box.tsx"; +import { useFetchDevices } from "../../use-fetch-devices.ts"; +import { TJoinProductionOptions } from "./types.ts"; import { SettingsModal, Hotkeys } from "./settings-modal.tsx"; import { CallState } from "../../global-state/types.ts"; import { ExitCallButton } from "./exit-call-button.tsx"; @@ -32,6 +44,9 @@ import { Modal } from "../modal/modal.tsx"; import { VerifyDecision } from "../verify-decision/verify-decision.tsx"; import { ModalConfirmationText } from "../modal/modal-confirmation-text.ts"; import { SymphonyRtcConnectionComponent } from "./symphony-rtc-connection-component.tsx"; +import { ReloadDevicesButton } from "../reload-devices-button.tsx/reload-devices-button.tsx"; + +type FormValues = TJoinProductionOptions; const TempDiv = styled.div` padding: 0 0 2rem 0; @@ -54,6 +69,10 @@ const SmallText = styled.span` font-size: 1.6rem; `; +const LargeText = styled.span` + word-break: break-all; +`; + const ButtonIcon = styled.div` width: 3rem; display: inline-block; @@ -94,8 +113,10 @@ const LongPressWrapper = styled.div` touch-action: none; `; -const ButtonWrapper = styled.span` - margin: 0 2rem 0 0; +const ButtonWrapper = styled.div` + display: flex; + justify-content: space-between; + margin: 0 2rem 2rem 1rem; `; const ListWrapper = styled(DisplayContainer)` @@ -135,10 +156,12 @@ export const ProductionLine = ({ isSingleCall, }: TProductionLine) => { const { productionId: paramProductionId, lineId: paramLineId } = useParams(); - const [, dispatch] = useGlobalState(); + const [{ devices }, dispatch] = useGlobalState(); const [connectionActive, setConnectionActive] = useState(true); const [isInputMuted, setIsInputMuted] = useState(true); const [isOutputMuted, setIsOutputMuted] = useState(false); + const [showDeviceSettings, setShowDeviceSettings] = useState(false); + const [refresh, setRefresh] = useState(0); const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [confirmExitModalOpen, setConfirmExitModalOpen] = useState(false); const [hotkeys, setHotkeys] = useState({ @@ -160,8 +183,9 @@ export const ProductionLine = ({ sessionId, } = callState; - const inputAudioStream = useAudioInput({ - inputId: joinProductionOptions?.audioinput ?? null, + const [inputAudioStream, resetAudioInput] = useAudioInput({ + audioInputId: joinProductionOptions?.audioinput ?? null, + audioOutputId: joinProductionOptions?.audiooutput ?? null, }); const muteInput = useCallback( @@ -195,6 +219,18 @@ export const ProductionLine = ({ customKeyPress: savedHotkeys.pressToTalkHotkey, }); + useFetchDevices({ + dispatch, + permission: true, + refresh, + }); + + useEffect(() => { + if (joinProductionOptions) { + setConnectionActive(true); + } + }, [joinProductionOptions]); + useEffect(() => { if (connectionState === "connected") { playEnterSound(); @@ -251,6 +287,79 @@ export const ProductionLine = ({ dispatch, }); + const { + formState: { isValid, isDirty }, + register, + handleSubmit, + } = useForm({ + defaultValues: { + username: "", + productionId: paramProductionId || "", + lineId: paramLineId || undefined, + }, + resetOptions: { + keepDirtyValues: true, // user-interacted input will be retained + keepErrors: true, // input errors will be retained with value update + }, + }); + + 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); + setShowDeviceSettings(!showDeviceSettings); + }; + + // Reset connection and re-connect to production-line + const onSubmit: SubmitHandler = async (payload) => { + const unchangedPayload = + payload.audioinput === joinProductionOptions?.audioinput && + payload.audiooutput === joinProductionOptions?.audiooutput; + if (joinProductionOptions && !unchangedPayload) { + setConnectionActive(false); + resetAudioInput(); + muteInput(true); + + const newJoinProductionOptions = { + ...payload, + productionId: joinProductionOptions.productionId, + lineId: joinProductionOptions.lineId, + username: joinProductionOptions.username, + }; + + dispatch({ + type: "UPDATE_CALL", + payload: { + id, + updates: { + devices: null, + joinProductionOptions: newJoinProductionOptions, + mediaStreamInput: null, + dominantSpeaker: null, + audioLevelAboveThreshold: false, + connectionState: null, + audioElements: null, + sessionId: null, + }, + }, + }); + + setShowDeviceSettings(false); + } + }; + const handleSettingsClick = () => { setIsSettingsModalOpen(!isSettingsModalOpen); }; @@ -285,8 +394,10 @@ export const ProductionLine = ({ {!loading && production && line && ( - Production: {production.name}{" "} - Line: {line.name} + Production: + {production.name} + Line: + {line.name} )} @@ -374,6 +485,78 @@ export const ProductionLine = ({ Audio Output: {deviceLabels.outputLabel} )} + + settingsButtonPressed()} + > + {!showDeviceSettings ? "Change device" : "Close"} + + + {showDeviceSettings && devices && ( + + + Input + + {inputDevices.length > 0 ? ( + inputDevices.map((device) => ( + + {device.label} + + )) + ) : ( + No device available + )} + + + + Output + {outputDevices.length > 0 ? ( + + {outputDevices.map((device) => ( + + {device.label} + + ))} + + ) : ( + + Controlled by operating system + + )} + + {isBrowserFirefox && !isMobile && ( + + If a new device has been added Firefox needs the + permission to be manually reset. If your device is + missing, please remove the permission and reload page. + + )} + + + Save + + {!(isBrowserFirefox && !isMobile) && ( + + setRefresh((prev) => prev + 1) + } + devices={devices} + /> + )} + + + )} {inputAudioStream && inputAudioStream !== "no-device" && diff --git a/src/components/production-line/use-audio-input.ts b/src/components/production-line/use-audio-input.ts index 7881013a..d542f778 100644 --- a/src/components/production-line/use-audio-input.ts +++ b/src/components/production-line/use-audio-input.ts @@ -1,53 +1,71 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { noop } from "../../helpers"; import { TJoinProductionOptions } from "./types.ts"; type TGetMediaDevicesOptions = { - inputId: TJoinProductionOptions["audioinput"] | null; + audioInputId: TJoinProductionOptions["audioinput"] | null; + audioOutputId: TJoinProductionOptions["audiooutput"] | null; }; export type TUseAudioInputValues = MediaStream | "no-device" | null; type TUseAudioInput = ( options: TGetMediaDevicesOptions -) => TUseAudioInputValues; +) => [TUseAudioInputValues, () => void]; // A hook for fetching the user selected audio input as a MediaStream -export const useAudioInput: TUseAudioInput = ({ inputId }) => { +export const useAudioInput: TUseAudioInput = ({ + audioInputId, + audioOutputId, +}) => { const [audioInput, setAudioInput] = useState(null); useEffect(() => { let aborted = false; - if (!inputId) return noop; + if (!audioInputId) return noop; - if (inputId === "no-device") return setAudioInput("no-device"); + if (audioInputId === "no-device") return setAudioInput("no-device"); - navigator.mediaDevices - .getUserMedia({ - audio: { - deviceId: { - exact: inputId, + // First request a generic audio stream to "reset" permissions + navigator.mediaDevices.getUserMedia({ audio: true }).then(() => { + // Then request the specific audio input the user has selected + navigator.mediaDevices + .getUserMedia({ + audio: { + deviceId: { + exact: audioInputId, + }, + noiseSuppression: true, }, - noiseSuppression: true, - }, - }) - .then((stream) => { - if (aborted) return; - - // Default to muted input - stream.getTracks().forEach((t) => { - // eslint-disable-next-line no-param-reassign - t.enabled = false; - }); + }) + .then((stream) => { + if (aborted) return; + + // Default to muted input + stream.getTracks().forEach((t) => { + // eslint-disable-next-line no-param-reassign + t.enabled = false; + }); - setAudioInput(stream); - }); + setAudioInput(stream); + }); + }); return () => { aborted = true; }; - }, [inputId]); + // audioOutputId is needed as a dependency to trigger restart of + // useEffect if only output has been updated during line-call + }, [audioInputId, audioOutputId]); + + // Reset function to set audioInput to null + const reset = useCallback(() => { + if (audioInput && audioInput !== "no-device") { + audioInput.getTracks().forEach((t) => t.stop()); + } + setAudioInput(null); + }, [audioInput]); - return audioInput; + return [audioInput, reset]; }; diff --git a/src/components/production-line/use-establish-session.ts b/src/components/production-line/use-establish-session.ts index 414844fe..20e1e67e 100644 --- a/src/components/production-line/use-establish-session.ts +++ b/src/components/production-line/use-establish-session.ts @@ -61,7 +61,6 @@ export const useEstablishSession = ({ useEffect( () => () => { if (!joinProductionOptions) return; - if (sessionId) { API.deleteAudioSession({ sessionId, diff --git a/src/components/production-line/use-rtc-connection.ts b/src/components/production-line/use-rtc-connection.ts index 3ffed3df..031cc10a 100644 --- a/src/components/production-line/use-rtc-connection.ts +++ b/src/components/production-line/use-rtc-connection.ts @@ -357,6 +357,7 @@ export const useRtcConnection = ({ sessionId, joinProductionOptions, rtcPeerConnection, + cleanUpAudio, dispatch, noStreamError, callId, diff --git a/src/components/reload-devices-button.tsx/reload-devices-button.tsx b/src/components/reload-devices-button.tsx/reload-devices-button.tsx new file mode 100644 index 00000000..9e6d4c3a --- /dev/null +++ b/src/components/reload-devices-button.tsx/reload-devices-button.tsx @@ -0,0 +1,71 @@ +import styled from "@emotion/styled"; +import { useEffect, useState } from "react"; +import { RefreshIcon } from "../../assets/icons/icon"; +import { PrimaryButton } from "../landing-page/form-elements"; +import { Spinner } from "../loader/loader"; +import { isBrowserFirefox, isMobile } from "../../bowser"; + +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 */ + + &.dummy { + background-color: #242424; + pointer-events: none; + } +`; + +export const ReloadDevicesButton = ({ + handleReloadDevices, + setFirefoxWarningModalOpen, + devices, + isDummy, +}: { + handleReloadDevices: () => void; + setFirefoxWarningModalOpen?: () => void; + devices: MediaDeviceInfo[]; + isDummy?: boolean; +}) => { + const [deviceRefresh, setDeviceRefresh] = useState(false); + + useEffect(() => { + let timeout: number | null = null; + + timeout = window.setTimeout(() => { + setDeviceRefresh(false); + }, 800); + + return () => { + if (timeout !== null) { + window.clearTimeout(timeout); + } + }; + }, [devices]); + + const reloadDevices = () => { + if (isBrowserFirefox && !isMobile && setFirefoxWarningModalOpen) { + setFirefoxWarningModalOpen(); + } else { + setDeviceRefresh(true); + handleReloadDevices(); + } + }; + + return ( + reloadDevices()} + > + {!deviceRefresh && } + {deviceRefresh && } + + ); +}; diff --git a/src/global-state/types.ts b/src/global-state/types.ts index 053a99ba..d02300da 100644 --- a/src/global-state/types.ts +++ b/src/global-state/types.ts @@ -9,8 +9,6 @@ export interface ErrorState { } export interface CallState { - production: TProduction | null; - reloadProductionList: boolean; devices: MediaDeviceInfo[] | null; joinProductionOptions: TJoinProductionOptions | null; mediaStreamInput: MediaStream | null; diff --git a/src/use-fetch-devices.ts b/src/use-fetch-devices.ts index 8ffd21e5..d3e965e0 100644 --- a/src/use-fetch-devices.ts +++ b/src/use-fetch-devices.ts @@ -4,9 +4,14 @@ import { TGlobalStateAction } from "./global-state/global-state-actions"; type TUseFetchDevices = { permission: boolean; dispatch: Dispatch; + refresh?: number; }; -export const useFetchDevices = ({ permission, dispatch }: TUseFetchDevices) => { +export const useFetchDevices = ({ + permission, + dispatch, + refresh, +}: TUseFetchDevices) => { useEffect(() => { if (permission) { window.navigator.mediaDevices @@ -26,5 +31,5 @@ export const useFetchDevices = ({ permission, dispatch }: TUseFetchDevices) => { } return () => {}; - }, [dispatch, permission]); + }, [dispatch, permission, refresh]); };
+ To reload devices Firefox needs the permission to be manually + reset, please remove permission and reload page instead. +