diff --git a/src/api/api.ts b/src/api/api.ts index d9c6a80c..df74c4bf 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -2,7 +2,7 @@ import { handleFetchRequest } from "./handle-fetch-request.ts"; const API_VERSION = import.meta.env.VITE_BACKEND_API_VERSION ?? "api/v1/"; const API_URL = - `${import.meta.env.VITE_BACKEND_URL}${API_VERSION}` ?? + `${import.meta.env.VITE_BACKEND_URL}${API_VERSION}` || `${window.location.origin}/${API_VERSION}`; const API_KEY = import.meta.env.VITE_BACKEND_API_KEY; diff --git a/src/assets/icons/icon.tsx b/src/assets/icons/icon.tsx index 9c709d36..98af7638 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 Settings from "./settings.svg?react"; export const MicMuted = () => ; @@ -28,3 +29,5 @@ export const ConfirmIcon = () => ; export const StepLeftIcon = () => ; export const StepRightIcon = () => ; + +export const SettingsIcon = () => ; diff --git a/src/assets/icons/settings.svg b/src/assets/icons/settings.svg new file mode 100644 index 00000000..2dd4809b --- /dev/null +++ b/src/assets/icons/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/production-line/production-line.tsx b/src/components/production-line/production-line.tsx index 33a581d0..d186ebe5 100644 --- a/src/components/production-line/production-line.tsx +++ b/src/components/production-line/production-line.tsx @@ -12,6 +12,7 @@ import { MicUnmuted, SpeakerOff, SpeakerOn, + SettingsIcon, } from "../../assets/icons/icon.tsx"; import { Spinner } from "../loader/loader.tsx"; import { DisplayContainerHeader } from "../landing-page/display-container-header.tsx"; @@ -29,11 +30,19 @@ import { useCheckBadLineData } from "./use-check-bad-line-data.ts"; import { NavigateToRootButton } from "../navigate-to-root-button/navigate-to-root-button.tsx"; import { useAudioCue } from "./use-audio-cue.ts"; import { DisplayWarning } from "../display-box.tsx"; +import { SettingsModal, Hotkeys } from "./settings-modal.tsx"; const TempDiv = styled.div` padding: 0 0 2rem 0; `; +const HotkeyDiv = styled.div` + padding: 0 0 2rem 0; + flex-direction: row; + display: flex; + align-items: center; +`; + const HeaderWrapper = styled.div` padding: 2rem; display: flex; @@ -51,6 +60,15 @@ const ButtonIcon = styled.div` margin: 0 auto; `; +const SettingsBtn = styled.div` + padding: 0; + margin-left: 1.5rem; + width: 3rem; + cursor: pointer; + color: white; + background: transparent; +`; + const FlexButtonWrapper = styled.div` width: 50%; padding: 0 1rem 2rem 1rem; @@ -110,6 +128,17 @@ export const ProductionLine: FC = () => { ] = useGlobalState(); const [isInputMuted, setIsInputMuted] = useState(true); const [isOutputMuted, setIsOutputMuted] = useState(false); + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); + const [hotkeys, setHotkeys] = useState({ + muteHotkey: "m", + speakerHotkey: "n", + pressToTalkHotkey: "t", + }); + const [savedHotkeys, setSavedHotkeys] = useState({ + muteHotkey: "m", + speakerHotkey: "n", + pressToTalkHotkey: "t", + }); const inputAudioStream = useAudioInput({ inputId: joinProductionOptions?.audioinput ?? null, @@ -141,6 +170,8 @@ export const ProductionLine: FC = () => { useLineHotkeys({ muteInput, isInputMuted, + customKeyMute: savedHotkeys.muteHotkey, + customKeyPress: savedHotkeys.pressToTalkHotkey, }); const { sessionId, sdpOffer } = useEstablishSession({ @@ -172,6 +203,7 @@ export const ProductionLine: FC = () => { useSpeakerHotkeys({ muteOutput, isOutputMuted, + customKey: savedHotkeys.speakerHotkey, }); const line = useLinePolling({ joinProductionOptions }); @@ -207,6 +239,15 @@ export const ProductionLine: FC = () => { dispatch, }); + const handleSettingsClick = () => { + setIsSettingsModalOpen(!isSettingsModalOpen); + }; + + const saveHotkeys = () => { + setSavedHotkeys({ ...hotkeys }); + setIsSettingsModalOpen(false); + }; + // TODO detect if browser back button is pressed and run exit(); return ( @@ -315,18 +356,37 @@ export const ProductionLine: FC = () => { inputAudioStream !== "no-device" && !isMobile && ( <> - + Hotkeys - + + + + - M: Toggle Input Mute + {savedHotkeys.muteHotkey.toUpperCase()}: + Toggle Input Mute - N: Toggle Output Mute + + {savedHotkeys.speakerHotkey.toUpperCase()}:{" "} + + Toggle Output Mute - T: Push to Talk + + {savedHotkeys.pressToTalkHotkey.toUpperCase()}:{" "} + + Push to Talk + {isSettingsModalOpen && ( + + )} )} diff --git a/src/components/production-line/settings-modal.tsx b/src/components/production-line/settings-modal.tsx new file mode 100644 index 00000000..9f578e97 --- /dev/null +++ b/src/components/production-line/settings-modal.tsx @@ -0,0 +1,252 @@ +import styled from "@emotion/styled"; +import React, { useRef, useState } from "react"; +import { ErrorMessage } from "@hookform/error-message"; +import { + PrimaryButton, + SecondaryButton, + FormLabel, + FormInput, + FormContainer, + DecorativeLabel, + StyledWarningMessage, +} from "../landing-page/form-elements"; + +const ModalOverlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.5); + z-index: 100; + display: flex; + justify-content: center; + align-items: center; +`; + +const ModalContent = styled.div` + background: #383838; + border-radius: 0.5rem; + padding: 2rem; + width: 80%; + max-width: 40rem; + box-shadow: 0 0.4rem 0.8rem rgba(0, 0, 0, 0.2); + color: white; +`; + +const ModalHeader = styled.h2` + font-size: 2rem; + margin-bottom: 2rem; + font-weight: 600; +`; + +const ModalCloseButton = styled.button` + background: none; + border: none; + position: absolute; + top: 1rem; + right: 1rem; + cursor: pointer; + font-size: 1.6rem; +`; + +const CancelButton = styled(SecondaryButton)` + background-color: #000000; +`; + +const ButtonDiv = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + margin-top: 3rem; +`; + +export type Hotkeys = { + muteHotkey: string; + speakerHotkey: string; + pressToTalkHotkey: string; +}; + +type TSettingsModalProps = { + hotkeys: Hotkeys; + lineName?: string; + setHotkeys: React.Dispatch>; + onClose: () => void; + onSave: () => void; +}; +export const SettingsModal = ({ + hotkeys, + lineName, + setHotkeys, + onClose, + onSave, +}: TSettingsModalProps) => { + const stopPropagation = (e: React.MouseEvent) => e.stopPropagation(); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + const [errors, setErrors] = useState<{ [key: string]: string }>({ + muteHotkey: "", + speakerHotkey: "", + pressToTalkHotkey: "", + }); + + const validateFields = (key: string, value: string) => { + const currentValues = { + ...hotkeys, + [key]: value, + }; + + const duplicates = Object.entries(currentValues).reduce( + (acc, [field, val]) => { + if (val && value && val === value && field !== key) { + acc[key] = "This key is already in use."; + } + return acc; + }, + {} as { [key: string]: string } + ); + + setErrors((prevErrors) => ({ + ...prevErrors, + muteHotkey: + key === "muteHotkey" + ? duplicates.muteHotkey || "" + : prevErrors.muteHotkey, + speakerHotkey: + key === "speakerHotkey" + ? duplicates.speakerHotkey || "" + : prevErrors.speakerHotkey, + pressToTalkHotkey: + key === "pressToTalkHotkey" + ? duplicates.pressToTalkHotkey || "" + : prevErrors.pressToTalkHotkey, + })); + }; + + const handleInputChange = (key: keyof typeof hotkeys, value: string) => { + if (value.length <= 1 && /^[a-zA-Z]?$/.test(value)) { + setHotkeys((prev) => ({ + ...prev, + [key]: value, + })); + validateFields(key, value); + } + }; + + const handleSave = () => { + const hasErrors = Object.values(errors).some((error) => error !== ""); + if (!hasErrors) { + onSave(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent, index: number) => { + if (e.key === "Enter") { + e.preventDefault(); + const nextInput = inputRefs.current[index + 1]; + if (nextInput) { + nextInput.focus(); + } else { + handleSave(); + } + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const previousInput = inputRefs.current[index - 1]; + if (previousInput) { + previousInput.focus(); + } + } else if (e.key === "ArrowDown") { + e.preventDefault(); + const nextInput = inputRefs.current[index + 1]; + if (nextInput) { + nextInput.focus(); + } + } + }; + + const setInputRef = (index: number, el: HTMLInputElement | null) => { + inputRefs.current[index] = el; + }; + + return ( + + + X + Hotkey settings for line: {lineName} + + + Toggle mute: + setInputRef(0, el)} + type="text" + placeholder="Enter hotkey" + value={hotkeys.muteHotkey} + onChange={(e) => handleInputChange("muteHotkey", e.target.value)} + maxLength={1} + onKeyDown={(e) => handleKeyDown(e, 0)} + /> + {errors.muteHotkey && ( + + )} + + + Toggle speaker: + setInputRef(1, el)} + type="text" + value={hotkeys.speakerHotkey} + onChange={(e) => + handleInputChange("speakerHotkey", e.target.value) + } + placeholder="Enter hotkey" + maxLength={1} + onKeyDown={(e) => handleKeyDown(e, 1)} + /> + {errors.speakerHotkey && ( + + )} + + + Toggle press to speak: + setInputRef(2, el)} + type="text" + value={hotkeys.pressToTalkHotkey} + onChange={(e) => + handleInputChange("pressToTalkHotkey", e.target.value) + } + placeholder="Enter hotkey" + maxLength={1} + onKeyDown={(e) => handleKeyDown(e, 2)} + /> + {errors.pressToTalkHotkey && ( + + )} + + + + Cancel + + + Save settings + + + + + + ); +}; diff --git a/src/components/production-line/use-line-hotkeys.ts b/src/components/production-line/use-line-hotkeys.ts index 48680349..bb3193b3 100644 --- a/src/components/production-line/use-line-hotkeys.ts +++ b/src/components/production-line/use-line-hotkeys.ts @@ -3,23 +3,31 @@ import { useHotkeys } from "react-hotkeys-hook"; type TuseLineHotkeys = { muteInput: (mute: boolean) => void; isInputMuted: boolean; + customKeyMute?: string; + customKeyPress?: string; }; type TuseSpeakerHotkeys = { muteOutput: (mute: boolean) => void; isOutputMuted: boolean; + customKey?: string; }; export const useLineHotkeys = ({ muteInput, isInputMuted, + customKeyMute, + customKeyPress, }: TuseLineHotkeys) => { - useHotkeys("m", () => { + const muteInputKey = customKeyMute || "m"; + const mutePressKey = customKeyPress || "t"; + + useHotkeys(muteInputKey, () => { muteInput(!isInputMuted); }); useHotkeys( - "t", + mutePressKey, (e) => { if (e.type === "keydown") { muteInput(false); @@ -37,8 +45,11 @@ export const useLineHotkeys = ({ export const useSpeakerHotkeys = ({ muteOutput, isOutputMuted, + customKey, }: TuseSpeakerHotkeys) => { - useHotkeys("n", () => { + const muteOutputKey = customKey || "n"; + + useHotkeys(muteOutputKey, () => { muteOutput(!isOutputMuted); }); };