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);
});
};