diff --git a/src/App.tsx b/src/App.tsx index bd79e026..66658444 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import { ManageProductionsPage } from "./components/manage-productions-page/mana import { CreateProductionPage } from "./components/create-production/create-production-page.tsx"; import { useSetupTokenRefresh } from "./hooks/use-reauth.tsx"; import { TUserSettings } from "./components/user-settings/types"; +import { AutoJoinPage } from "./components/auto-join/auto-join-page.tsx"; const DisplayBoxPositioningContainer = styled(FlexContainer)` justify-content: center; @@ -107,68 +108,70 @@ const AppContent = ({ /> )} - {continueToApp && ( - <> - {denied && ( - - - - )} - {!permission && !denied && ( - - - - )} - {apiError && ( - - - - )} - {permission && !denied && !apiError && userSettings && ( - - <> - setApiError(true)} /> - } - errorElement={} - /> - } - errorElement={} - /> - setApiError(true)} - /> - } - errorElement={} - /> - } - errorElement={} - /> - } /> - - - )} - + {continueToApp && denied && ( + + + )} + {continueToApp && !permission && !denied && ( + + + + )} + {continueToApp && apiError && ( + + + + )} + + + {permission && !denied && !apiError && userSettings && ( + <> + setApiError(true)} />} + errorElement={} + /> + } + errorElement={} + /> + setApiError(true)} /> + } + errorElement={} + /> + } + errorElement={} + /> + } + errorElement={} + /> + } + errorElement={} + /> + + )} + } /> + ); }; diff --git a/src/api/api.ts b/src/api/api.ts index 532b1f90..972b2aef 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,9 +1,12 @@ 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.replace(/\/+$/, "")}/${API_VERSION}` || - `${window.location.origin}/${API_VERSION}`; +const API_VERSION = ( + import.meta.env.VITE_BACKEND_API_VERSION ?? "api/v1" +).replace(/\/+$/, ""); +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL; +const API_URL = BACKEND_URL + ? `${BACKEND_URL.replace(/\/+$/, "")}/${API_VERSION}/` + : `${window.location.origin}/${API_VERSION}/`; const API_KEY = import.meta.env.VITE_BACKEND_API_KEY; type TCreateProductionOptions = { diff --git a/src/components/auto-join/auto-join-link-modal.tsx b/src/components/auto-join/auto-join-link-modal.tsx new file mode 100644 index 00000000..872f3d26 --- /dev/null +++ b/src/components/auto-join/auto-join-link-modal.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState } from "react"; +import styled from "@emotion/styled"; +import { CopyButton } from "../copy-button/copy-button"; +import { DecorativeLabel, FormInput } from "../form-elements/form-elements"; +import { Modal } from "../modal/modal"; +import { + InputWrapper, + LinkLabel, + ModalHeader, + ModalText, + Wrapper, +} from "../generate-urls/generate-urls-components"; + +const CheckboxRow = styled.label` + display: flex; + align-items: center; + gap: 1rem; + font-size: 1.4rem; + color: rgba(255, 255, 255, 0.8); + cursor: pointer; + margin-top: 1.5rem; + + input[type="checkbox"] { + width: 1.6rem; + height: 1.6rem; + cursor: pointer; + accent-color: #59cbe8; + } +`; + +const COMPANION_SUFFIX = "&companion=ws://127.0.0.1:12345"; + +type TAutoJoinLinkModalProps = { + url: string; + onClose: () => void; +}; + +export const AutoJoinLinkModal = ({ + url, + onClose, +}: TAutoJoinLinkModalProps) => { + const modalRef = useRef(null); + const [includeCompanion, setIncludeCompanion] = useState(false); + const [username, setUsername] = useState(""); + + const usernameParam = username + ? `&username=${encodeURIComponent(username)}` + : ""; + const displayUrl = `${url}${usernameParam}${includeCompanion ? COMPANION_SUFFIX : ""}`; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + modalRef.current && + !modalRef.current.contains(event.target as Node) + ) { + onClose(); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [onClose]); + + return ( + +
+ Auto Join Link + + Share this link to let someone join the same set of calls + automatically. + + + + + Username (optional) + setUsername(e.target.value)} + /> + + + + + setIncludeCompanion(e.target.checked)} + /> + Include companion connection + + + + + + + + + +
+
+ ); +}; diff --git a/src/components/auto-join/auto-join-page.tsx b/src/components/auto-join/auto-join-page.tsx new file mode 100644 index 00000000..8d55fa61 --- /dev/null +++ b/src/components/auto-join/auto-join-page.tsx @@ -0,0 +1,65 @@ +import { useEffect, useRef } from "react"; +import { useSearchParams, useNavigate } from "react-router"; +import { useGlobalState } from "../../global-state/context-provider"; +import { useInitiateProductionCall } from "../../hooks/use-initiate-production-call"; +import { AUTO_JOIN_STORAGE_KEY, TAutoJoinCall } from "../../utils/auto-join"; + +export const AutoJoinPage = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [{ userSettings, devices }, dispatch] = useGlobalState(); + const { initiateProductionCall } = useInitiateProductionCall({ dispatch }); + const hasInitiated = useRef(false); + + useEffect(() => { + if (hasInitiated.current) return; + + const callsParam = searchParams.get("calls") ?? ""; + const usernameParam = searchParams.get("username"); + const companionParam = searchParams.get("companion"); + const username = usernameParam || userSettings?.username || "Auto"; + + const calls: TAutoJoinCall[] = callsParam + .split(",") + .map((pair) => pair.split(":")) + .filter(([p, l]) => p && l) + .map(([productionId, lineId]) => ({ productionId, lineId })); + + if (calls.length === 0) { + navigate("/"); + return; + } + + hasInitiated.current = true; + + localStorage.setItem(AUTO_JOIN_STORAGE_KEY, JSON.stringify(calls)); + + if (companionParam) { + localStorage.setItem("companion_auto_connect", companionParam); + } + + const audiooutput = userSettings?.audiooutput; + const audioinput = + userSettings?.audioinput || devices?.input?.[0]?.deviceId; + + Promise.all( + calls.map((call) => + initiateProductionCall({ + payload: { + joinProductionOptions: { + productionId: call.productionId, + lineId: call.lineId, + username, + audioinput, + lineUsedForProgramOutput: false, + isProgramUser: false, + }, + audiooutput, + }, + }) + ) + ).then(() => navigate("/production-calls")); + }, [searchParams, userSettings, devices, navigate, initiateProductionCall]); + + return null; +}; diff --git a/src/components/calls-page/calls-page.tsx b/src/components/calls-page/calls-page.tsx index 26f874a8..3d89f4cb 100644 --- a/src/components/calls-page/calls-page.tsx +++ b/src/components/calls-page/calls-page.tsx @@ -1,8 +1,12 @@ import styled from "@emotion/styled"; import { useEffect, useRef, useState } from "react"; import { useParams } from "react-router"; +import { ShareIcon } from "../../assets/icons/icon"; import { useGlobalState } from "../../global-state/context-provider"; +import { useAutoJoinRestore } from "../../hooks/use-auto-join-restore"; import { useCallList } from "../../hooks/use-call-list"; +import { AutoJoinLinkModal } from "../auto-join/auto-join-link-modal"; +import { CopyIconWrapper } from "../copy-button/copy-components"; import { JoinProduction } from "../landing-page/join-production"; import { UserSettingsButton } from "../landing-page/user-settings-button"; import { Modal } from "../modal/modal"; @@ -46,6 +50,7 @@ const CallsContainer = styled.div` export const CallsPage = () => { const [productionId, setProductionId] = useState(null); const [addCallActive, setAddCallActive] = useState(false); + const [autoJoinModalOpen, setAutoJoinModalOpen] = useState(false); const [confirmExitModalOpen, setConfirmExitModalOpen] = useState(false); const [isMasterInputMuted, setIsMasterInputMuted] = useState(true); @@ -64,9 +69,20 @@ export const CallsPage = () => { const [showSettings, setShowSettings] = useState(false); const [isSettingGlobalMute, setIsSettingGlobalMute] = useState(false); + const [companionAutoConnectUrl, setCompanionAutoConnectUrl] = useState< + string | null + >(null); const { productionId: paramProductionId, lineId: paramLineId } = useParams(); + useEffect(() => { + const storedCompanionUrl = localStorage.getItem("companion_auto_connect"); + if (storedCompanionUrl && !companionAutoConnectUrl) { + setCompanionAutoConnectUrl(storedCompanionUrl); + localStorage.removeItem("companion_auto_connect"); + } + }, [companionAutoConnectUrl]); + const navigate = useCallsNavigation({ isEmpty: Object.values(calls).length === 0, paramProductionId, @@ -106,6 +122,7 @@ export const CallsPage = () => { }, [calls]); usePreventPullToRefresh(); + useAutoJoinRestore(); useEffect(() => { if (selectedProductionId) { @@ -151,6 +168,34 @@ export const CallsPage = () => { )} + setAutoJoinModalOpen(true)} + style={{ marginTop: "2px", marginLeft: "2px" }} + > + + + {autoJoinModalOpen && ( + { + const { productionId: pid, lineId: lid } = + c.joinProductionOptions ?? {}; + return pid && lid ? `${pid}:${lid}` : null; + }) + .filter(Boolean) + .join(",")}`} + onClose={() => setAutoJoinModalOpen(false)} + /> + )} + + ) : undefined + } hasNavigateToRoot onNavigateToRoot={() => { if (isEmpty) { @@ -193,6 +238,7 @@ export const CallsPage = () => { callActionHandlers={callActionHandlers} sendCallsStateUpdate={sendCallsStateUpdate} resetLastSentCallsState={resetLastSentCallsState} + companionAutoConnectUrl={companionAutoConnectUrl} /> diff --git a/src/components/calls-page/connect-to-ws-button.tsx b/src/components/calls-page/connect-to-ws-button.tsx index dac9e9f6..affc9bff 100644 --- a/src/components/calls-page/connect-to-ws-button.tsx +++ b/src/components/calls-page/connect-to-ws-button.tsx @@ -71,6 +71,7 @@ interface ConnectToWSButtonProps { Record void>> >; isMasterInputMuted: boolean; + companionAutoConnectUrl: string | null; handleToggleGlobalMute: () => void; sendCallsStateUpdate: () => void; resetLastSentCallsState: () => void; @@ -80,6 +81,7 @@ export const ConnectToWSButton = ({ callIndexMap, callActionHandlers, isMasterInputMuted, + companionAutoConnectUrl, handleToggleGlobalMute, sendCallsStateUpdate, resetLastSentCallsState, @@ -88,6 +90,7 @@ export const ConnectToWSButton = ({ const [isWSReconnecting, setIsWSReconnecting] = useState(false); const [isConnectionConflict, setConnectionConflict] = useState(false); const [{ calls }, dispatch] = useGlobalState(); + const [hasAutoConnected, setHasAutoConnected] = useState(false); // map call ids to indices for actions useEffect(() => { @@ -129,6 +132,22 @@ export const ConnectToWSButton = ({ wsConnect, }); + // Auto-connect to Companion if companionAutoConnectUrl is provided + useEffect(() => { + if (hasAutoConnected || isWSConnected || !companionAutoConnectUrl) { + return; + } + + setHasAutoConnected(true); + + if ( + companionAutoConnectUrl.startsWith("ws://") || + companionAutoConnectUrl.startsWith("wss://") + ) { + wsConnect(companionAutoConnectUrl); + } + }, [hasAutoConnected, isWSConnected, companionAutoConnectUrl, wsConnect]); + const handleConnect = (url: string) => { setConnectionConflict(false); wsConnect(url); diff --git a/src/components/calls-page/header-actions.tsx b/src/components/calls-page/header-actions.tsx index 3ff457cb..ecbe794a 100644 --- a/src/components/calls-page/header-actions.tsx +++ b/src/components/calls-page/header-actions.tsx @@ -52,6 +52,7 @@ type HeaderActionsProps = { callActionHandlers: React.MutableRefObject< Record void>> >; + companionAutoConnectUrl: string | null; setIsMasterInputMuted: React.Dispatch>; setAddCallActive: (addCallActive: boolean) => void; setIsSettingGlobalMute: React.Dispatch>; @@ -66,6 +67,7 @@ export const HeaderActions = ({ callIndexMap, callActionHandlers, addCallActive, + companionAutoConnectUrl, setAddCallActive, setIsSettingGlobalMute, sendCallsStateUpdate, @@ -86,6 +88,7 @@ export const HeaderActions = ({ sendCallsStateUpdate={sendCallsStateUpdate} resetLastSentCallsState={resetLastSentCallsState} handleToggleGlobalMute={handleToggleGlobalMute} + companionAutoConnectUrl={companionAutoConnectUrl} /> )} {!isEmpty && !isSingleCall && !isMobile && ( diff --git a/src/components/page-layout/page-header.tsx b/src/components/page-layout/page-header.tsx index ab23b9f0..5949b745 100644 --- a/src/components/page-layout/page-header.tsx +++ b/src/components/page-layout/page-header.tsx @@ -1,5 +1,5 @@ import styled from "@emotion/styled"; -import { FC, PropsWithChildren } from "react"; +import { FC, PropsWithChildren, ReactNode } from "react"; import { DisplayContainer } from "../generic-components"; import { DisplayContainerHeader } from "../landing-page/display-container-header"; import { LoaderDots } from "../loader/loader"; @@ -54,6 +54,7 @@ const RootButtonWrapper = styled.div` interface PageHeaderProps extends PropsWithChildren { title: string; + titleAction?: ReactNode; hasNavigateToRoot?: boolean; onNavigateToRoot?: () => void; loading?: boolean; @@ -62,6 +63,7 @@ interface PageHeaderProps extends PropsWithChildren { export const PageHeader: FC = (props) => { const { title, + titleAction, hasNavigateToRoot = false, onNavigateToRoot, loading = false, @@ -77,6 +79,7 @@ export const PageHeader: FC = (props) => { )} {title} + {titleAction} diff --git a/src/hooks/use-auto-join-restore.ts b/src/hooks/use-auto-join-restore.ts new file mode 100644 index 00000000..a24d4c39 --- /dev/null +++ b/src/hooks/use-auto-join-restore.ts @@ -0,0 +1,63 @@ +import { useEffect, useRef } from "react"; +import { useGlobalState } from "../global-state/context-provider"; +import { useInitiateProductionCall } from "./use-initiate-production-call"; +import { AUTO_JOIN_STORAGE_KEY, TAutoJoinCall } from "../utils/auto-join"; + +export const useAutoJoinRestore = () => { + const [{ calls, userSettings, devices }, dispatch] = useGlobalState(); + const { initiateProductionCall } = useInitiateProductionCall({ dispatch }); + const callsWereActive = useRef(false); + const hasRestored = useRef(false); + + // On mount: restore calls if localStorage has a saved config and state is empty + useEffect(() => { + if (hasRestored.current) return; + + const stored = localStorage.getItem(AUTO_JOIN_STORAGE_KEY); + if (!stored || Object.keys(calls).length > 0) return; + + let savedCalls: TAutoJoinCall[]; + try { + savedCalls = JSON.parse(stored); + } catch { + localStorage.removeItem(AUTO_JOIN_STORAGE_KEY); + return; + } + + if (savedCalls.length === 0) return; + + hasRestored.current = true; + + const username = userSettings?.username || "Auto"; + const audiooutput = userSettings?.audiooutput; + const audioinput = + userSettings?.audioinput || devices?.input?.[0]?.deviceId; + + savedCalls.forEach((call) => { + initiateProductionCall({ + payload: { + joinProductionOptions: { + productionId: call.productionId, + lineId: call.lineId, + username, + audioinput, + lineUsedForProgramOutput: false, + isProgramUser: false, + }, + audiooutput, + }, + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Clear stored config when the user intentionally empties all calls + useEffect(() => { + if (Object.keys(calls).length > 0) { + callsWereActive.current = true; + } else if (callsWereActive.current) { + localStorage.removeItem(AUTO_JOIN_STORAGE_KEY); + callsWereActive.current = false; + } + }, [calls]); +}; diff --git a/src/hooks/use-call-list.ts b/src/hooks/use-call-list.ts index 0807ee4a..c6653418 100644 --- a/src/hooks/use-call-list.ts +++ b/src/hooks/use-call-list.ts @@ -100,7 +100,9 @@ export function useCallList({ ((data.isProgramOutputLine && !data.isProgramUser) || !data.isProgramOutputLine)) || prev.volume !== data.volume || - prev.isSomeoneSpeaking !== data.isSomeoneSpeaking; + prev.isSomeoneSpeaking !== data.isSomeoneSpeaking || + prev.lineName !== data.lineName || + prev.productionName !== data.productionName; if (!hasChanged) return; diff --git a/src/utils/auto-join.ts b/src/utils/auto-join.ts new file mode 100644 index 00000000..e748ad26 --- /dev/null +++ b/src/utils/auto-join.ts @@ -0,0 +1,6 @@ +export const AUTO_JOIN_STORAGE_KEY = "auto_join_calls"; + +export type TAutoJoinCall = { + productionId: string; + lineId: string; +};