From cc96d490da12cd40ae9990e7339cfdcf6fa6cb60 Mon Sep 17 00:00:00 2001 From: Per Enstedt Date: Mon, 27 Oct 2025 10:35:18 +0100 Subject: [PATCH 1/5] feat: initial commit --- src/App.tsx | 8 + src/api/api.ts | 243 +++++++++++- src/assets/icons/icon.tsx | 6 + src/assets/icons/play.svg | 3 + src/assets/icons/stop.svg | 3 + .../delete-button/delete-button-components.ts | 1 + .../add-receiver-modal/add-receiver-form.tsx | 208 +++++++++++ .../add-transmitter-form.tsx | 271 ++++++++++++++ .../io-bridge-page/io-bridge-components.tsx | 158 ++++++++ .../io-bridge-page/io-bridge-page.tsx | 155 ++++++++ .../receiver-expanded-content.tsx | 351 +++++++++++++++++ .../io-bridge-page/receiver-item.tsx | 77 ++++ .../transmitter-expanded-content.tsx | 352 ++++++++++++++++++ .../io-bridge-page/transmitter-item.tsx | 84 +++++ .../productions-list-container.tsx | 10 +- src/hooks/use-bridge-config.tsx | 31 ++ src/hooks/use-create-receiver.tsx | 39 ++ src/hooks/use-create-transmitter.tsx | 48 +++ src/hooks/use-delete-receiver.tsx | 10 + src/hooks/use-delete-transmitter.tsx | 10 + src/hooks/use-edit-receiver.tsx | 33 ++ src/hooks/use-edit-transmitter.tsx | 40 ++ src/hooks/use-fetch-transmitter.tsx | 67 ++++ src/hooks/use-receiver-list.tsx | 49 +++ src/hooks/use-transmitter-list.tsx | 51 +++ src/hooks/use-update-receiver.tsx | 27 ++ src/hooks/use-update-transmitter.tsx | 27 ++ 27 files changed, 2358 insertions(+), 4 deletions(-) create mode 100644 src/assets/icons/play.svg create mode 100644 src/assets/icons/stop.svg create mode 100644 src/components/io-bridge-page/add-receiver-modal/add-receiver-form.tsx create mode 100644 src/components/io-bridge-page/add-transmitter-modal/add-transmitter-form.tsx create mode 100644 src/components/io-bridge-page/io-bridge-components.tsx create mode 100644 src/components/io-bridge-page/io-bridge-page.tsx create mode 100644 src/components/io-bridge-page/receiver-expanded-content.tsx create mode 100644 src/components/io-bridge-page/receiver-item.tsx create mode 100644 src/components/io-bridge-page/transmitter-expanded-content.tsx create mode 100644 src/components/io-bridge-page/transmitter-item.tsx create mode 100644 src/hooks/use-bridge-config.tsx create mode 100644 src/hooks/use-create-receiver.tsx create mode 100644 src/hooks/use-create-transmitter.tsx create mode 100644 src/hooks/use-delete-receiver.tsx create mode 100644 src/hooks/use-delete-transmitter.tsx create mode 100644 src/hooks/use-edit-receiver.tsx create mode 100644 src/hooks/use-edit-transmitter.tsx create mode 100644 src/hooks/use-fetch-transmitter.tsx create mode 100644 src/hooks/use-receiver-list.tsx create mode 100644 src/hooks/use-transmitter-list.tsx create mode 100644 src/hooks/use-update-receiver.tsx create mode 100644 src/hooks/use-update-transmitter.tsx diff --git a/src/App.tsx b/src/App.tsx index cf0ebf87..7a78ee63 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 { IOBridgePage } from "./components/io-bridge-page/io-bridge-page.tsx"; const DisplayBoxPositioningContainer = styled(FlexContainer)` justify-content: center; @@ -152,6 +153,13 @@ const AppContent = ({ } errorElement={} /> + setApiError(true)} /> + } + errorElement={} + /> } diff --git a/src/api/api.ts b/src/api/api.ts index 532b1f90..9c150d68 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,11 +1,18 @@ 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_URL = import.meta.env.VITE_BACKEND_URL + ? `${import.meta.env.VITE_BACKEND_URL.replace(/\/+$/, "")}/${API_VERSION}` + : `${window.location.origin}/${API_VERSION}`; const API_KEY = import.meta.env.VITE_BACKEND_API_KEY; +// Helper to get backend base URL for constructing WHIP/WHEP URLs +export const getBackendBaseUrl = (): string => { + return import.meta.env.VITE_BACKEND_URL + ? import.meta.env.VITE_BACKEND_URL.replace(/\/+$/, "") + : window.location.origin; +}; + type TCreateProductionOptions = { name: string; lines: { name: string; programOutputLine?: boolean }[]; @@ -33,6 +40,74 @@ export type TBasicProductionResponse = { lines: TLine[]; }; +export type TSavedTransmitter = { + _id: string; + label?: string; + port: number; + productionId: number; + lineId: number; + whipUrl: string; + passThroughUrl?: string; + mode: "caller" | "listener"; + srtUrl?: string; + status: "idle" | "running" | "stopped" | "failed"; + createdAt?: string; + updatedAt?: string; +}; + +export type TSavedReceiver = { + _id: string; + label?: string; + productionId: number; + lineId: number; + whepUrl: string; + srtUrl: string; + status: "idle" | "running" | "stopped" | "failed"; + createdAt?: string; + updatedAt?: string; +}; + +export enum TSrtMode { + CALLER = "caller", + LISTENER = "listener", +} + +export enum TBridgeState { + IDLE = "idle", + RUNNING = "running", + STOPPED = "stopped", + FAILED = "failed", +} + +export type TEditTransmitter = { + port: string; + state: TBridgeState; +}; + +export type TEditReceiver = { + id: string; + state: TBridgeState; +}; + +export type TPatchTransmitter = { + port: string; + label?: string; + productionId?: number; + lineId?: number; +}; + +export type TPatchReceiver = { + id: string; + label?: string; + productionId?: number; + lineId?: number; +}; + +export type TBridgeConfig = { + whipGatewayEnabled: boolean; + whepGatewayEnabled: boolean; +}; + export type TListProductionsResponse = { productions: TBasicProductionResponse[]; offset: 0; @@ -292,4 +367,166 @@ export const API = { }) ); }, + + // Bridge configuration + fetchBridgeConfig: (): Promise => + handleFetchRequest( + fetch(`${API_URL}bridge/config`, { + method: "GET", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), + + // Transmitter (Bridge TX) endpoints - via intercom-manager + createTransmitter: async (data: { + label?: string; + port: number; + productionId: number; + lineId: number; + whipUrl: string; + passThroughUrl?: string; + mode: "caller" | "listener"; + srtUrl?: string; + }) => + handleFetchRequest( + fetch(`${API_URL}bridge/tx`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + body: JSON.stringify(data), + }) + ), + fetchTransmitterList: (): Promise => + handleFetchRequest<{ transmitters: TSavedTransmitter[] }>( + fetch(`${API_URL}bridge/tx`, { + method: "GET", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ).then((res) => res?.transmitters || []), + fetchTransmitter: (port: number): Promise => + handleFetchRequest( + fetch(`${API_URL}bridge/tx/${port}`, { + method: "GET", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), + updateTransmitterState: async (data: TEditTransmitter) => + handleFetchRequest( + fetch(`${API_URL}bridge/tx/${data.port}/state`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + body: JSON.stringify({ + desired: data.state, + }), + }) + ), + updateTransmitter: async (data: TPatchTransmitter) => + handleFetchRequest( + fetch(`${API_URL}bridge/tx/${data.port}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + body: JSON.stringify({ + label: data.label, + productionId: data.productionId, + lineId: data.lineId, + }), + }) + ), + deleteTransmitter: async (port: string): Promise => + handleFetchRequest( + fetch(`${API_URL}bridge/tx/${port}`, { + method: "DELETE", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), + + // Receiver (Bridge RX) endpoints - via intercom-manager + createReceiver: async (data: { + label?: string; + productionId: number; + lineId: number; + whepUrl: string; + srtUrl: string; + }) => + handleFetchRequest( + fetch(`${API_URL}bridge/rx`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + body: JSON.stringify(data), + }) + ), + fetchReceiverList: (): Promise => + handleFetchRequest<{ receivers: TSavedReceiver[] }>( + fetch(`${API_URL}bridge/rx`, { + method: "GET", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ).then((res) => res?.receivers || []), + fetchReceiver: (id: string): Promise => + handleFetchRequest( + fetch(`${API_URL}bridge/rx/${id}`, { + method: "GET", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), + updateReceiverState: async (data: TEditReceiver) => + handleFetchRequest( + fetch(`${API_URL}bridge/rx/${data.id}/state`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + body: JSON.stringify({ + desired: data.state, + }), + }) + ), + updateReceiver: async (data: TPatchReceiver) => + handleFetchRequest( + fetch(`${API_URL}bridge/rx/${data.id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + body: JSON.stringify({ + label: data.label, + productionId: data.productionId, + lineId: data.lineId, + }), + }) + ), + deleteReceiver: async (id: string): Promise => + handleFetchRequest( + fetch(`${API_URL}bridge/rx/${id}`, { + method: "DELETE", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), }; diff --git a/src/assets/icons/icon.tsx b/src/assets/icons/icon.tsx index 26aeeec1..2fc1383d 100644 --- a/src/assets/icons/icon.tsx +++ b/src/assets/icons/icon.tsx @@ -25,6 +25,8 @@ import VolumeOff from "./volume_off.svg?react"; import VolumeOn from "./volume_on.svg?react"; import Warning from "./warning.svg?react"; import WhipSvg from "./whip_user.svg?react"; +import Stop from "./stop.svg?react"; +import Play from "./play.svg?react"; export const MicMuted = () => ; @@ -79,3 +81,7 @@ export const SaveIcon = () => ; export const HelpIcon = () => ; export const WarningIcon = () => ; + +export const StopIcon = () => ; + +export const PlayIcon = () => ; diff --git a/src/assets/icons/play.svg b/src/assets/icons/play.svg new file mode 100644 index 00000000..e7b4cb00 --- /dev/null +++ b/src/assets/icons/play.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/icons/stop.svg b/src/assets/icons/stop.svg new file mode 100644 index 00000000..cf7e5027 --- /dev/null +++ b/src/assets/icons/stop.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/components/delete-button/delete-button-components.ts b/src/components/delete-button/delete-button-components.ts index 5ef000e4..bbd9f94f 100644 --- a/src/components/delete-button/delete-button-components.ts +++ b/src/components/delete-button/delete-button-components.ts @@ -5,6 +5,7 @@ export const ButtonsWrapper = styled.div` display: flex; justify-content: flex-end; margin: 1rem 0 1rem 0; + gap: 1rem; `; export const DeleteButton = styled(SecondaryButton)` diff --git a/src/components/io-bridge-page/add-receiver-modal/add-receiver-form.tsx b/src/components/io-bridge-page/add-receiver-modal/add-receiver-form.tsx new file mode 100644 index 00000000..54951ab8 --- /dev/null +++ b/src/components/io-bridge-page/add-receiver-modal/add-receiver-form.tsx @@ -0,0 +1,208 @@ +import { SubmitHandler, useForm, useWatch } from "react-hook-form"; +import { useEffect, useMemo, useState } from "react"; +import { + BoldHeader, + BoldText, + FieldHeader, + FormWrapper, + SubmitButton, +} from "../io-bridge-components"; +import { FormItem } from "../../user-settings-form/form-item"; +import { + DecorativeLabel, + FormInput, + FormSelect, +} from "../../form-elements/form-elements"; +import { useFetchProductionList } from "../../landing-page/use-fetch-production-list"; +import { TLine } from "../../production-line/types"; +import { ButtonWrapper } from "../../generic-components"; +import { useCreateReceiver } from "../../../hooks/use-create-receiver"; +import { useSubmitOnEnter } from "../../../hooks/use-submit-form-enter-press"; +import { SpinnerWrapper } from "../../delete-button/delete-button-components"; +import { Spinner } from "../../loader/loader"; + +type FormValues = { + label?: string; + productionId: number; + lineId: number; + whepUsername: string; + srtUrl: string; +}; + +type AddReceiverFormProps = { + onSave?: () => void; +}; + +export const AddReceiverForm = ({ onSave }: AddReceiverFormProps) => { + const [createReceiver, setCreateReceiver] = useState(null); + const { + formState: { errors }, + register, + handleSubmit, + control, + setValue, + } = useForm({ + resetOptions: { keepDirtyValues: true, keepErrors: true }, + }); + + const { productions } = useFetchProductionList({ extended: "true" }); + const { loading, success } = useCreateReceiver({ createReceiver }); + + useEffect(() => { + if (success) { + setCreateReceiver(null); + if (onSave) onSave(); + } + }, [success, onSave]); + + const onSubmit: SubmitHandler = (data) => { + setCreateReceiver(data); + }; + + useSubmitOnEnter({ + handleSubmit, + submitHandler: onSubmit, + shouldSubmitOnEnter: true, + }); + + const selectedProductionId = useWatch({ control, name: "productionId" }); + + const selectedProduction = useMemo(() => { + const list = productions?.productions ?? []; + return list.find( + (p) => String(p.productionId) === String(selectedProductionId) + ); + }, [productions, selectedProductionId]); + + const lines = selectedProduction?.lines; + + useEffect(() => { + const list = productions?.productions ?? []; + if (!list.length) return; + if (!selectedProductionId) { + setValue("productionId", Number(list[0].productionId), { + shouldValidate: true, + shouldDirty: false, + }); + } + }, [productions, selectedProductionId, setValue]); + + useEffect(() => { + if (lines && lines[0]) { + setValue("lineId", Number(lines[0].id), { + shouldValidate: true, + shouldDirty: false, + }); + } + }, [lines, setValue]); + + return ( + <> + Add WHEP to SRT receiver + + + + Label(Optional) + + + + + + Production + {productions?.productions?.length ? ( + Number(v), + })} + > + {productions.productions.map((p) => ( + + ))} + + ) : ( + No productions available + )} + + + + Line + Number(v), + })} + disabled={!selectedProductionId || !lines} + > + {!lines ? ( + + ) : ( + lines.map((line: TLine) => ( + + )) + )} + + + + + WHEP Username + + + + + SRT Output URL + +
+ Default SRT mode is caller.

+ SRT Listener URL example: srt://0.0.0.0:10000?mode=listener +
+
+ + + + Add Receiver + {loading && ( + + + + )} + + +
+ + ); +}; diff --git a/src/components/io-bridge-page/add-transmitter-modal/add-transmitter-form.tsx b/src/components/io-bridge-page/add-transmitter-modal/add-transmitter-form.tsx new file mode 100644 index 00000000..caa7f233 --- /dev/null +++ b/src/components/io-bridge-page/add-transmitter-modal/add-transmitter-form.tsx @@ -0,0 +1,271 @@ +import { SubmitHandler, useForm, useWatch } from "react-hook-form"; +import { useEffect, useMemo, useState } from "react"; +import { + BoldHeader, + BoldText, + Collapsible, + FieldHeader, + FormWrapper, + SubmitButton, +} from "../io-bridge-components"; +import { FormItem } from "../../user-settings-form/form-item"; +import { + DecorativeLabel, + FormInput, + FormSelect, +} from "../../form-elements/form-elements"; +import { useFetchProductionList } from "../../landing-page/use-fetch-production-list"; +import { TLine } from "../../production-line/types"; +import { ButtonWrapper } from "../../generic-components"; +import { useCreateTransmitter } from "../../../hooks/use-create-transmitter"; +import { useSubmitOnEnter } from "../../../hooks/use-submit-form-enter-press"; +import { SpinnerWrapper } from "../../delete-button/delete-button-components"; +import { Spinner } from "../../loader/loader"; + +type FormValues = { + label?: string; + port: number; + mode: "caller" | "listener"; + srtUrl?: string; + productionId: number; + lineId: number; + passThroughUrl?: string; + whipUsername: string; + status: "idle" | "running" | "stopped" | "failed"; +}; + +type AddTransmitterFormProps = { + onSave?: () => void; +}; + +export const AddTransmitterForm = ({ onSave }: AddTransmitterFormProps) => { + const srtModeOptions = ["caller", "listener"]; + const [createTransmitter, setCreateTransmitter] = useState( + null + ); + const { + formState: { errors }, + register, + handleSubmit, + control, + setValue, + clearErrors, + } = useForm({ + resetOptions: { keepDirtyValues: true, keepErrors: true }, + defaultValues: { mode: "listener" }, + }); + + const { productions } = useFetchProductionList({ extended: "true" }); + const { loading, success } = useCreateTransmitter({ createTransmitter }); + const mode = useWatch({ control, name: "mode" }); + + useEffect(() => { + if (mode !== "caller") { + setValue("srtUrl", ""); + clearErrors("srtUrl"); + } + }, [mode, setValue, clearErrors]); + + useEffect(() => { + if (success) { + setCreateTransmitter(null); + if (onSave) onSave(); + } + }, [success, onSave]); + + const onSubmit: SubmitHandler = (data) => { + setCreateTransmitter(data); + }; + + useSubmitOnEnter({ + handleSubmit, + submitHandler: onSubmit, + shouldSubmitOnEnter: true, + }); + + const selectedProductionId = useWatch({ control, name: "productionId" }); + + const selectedProduction = useMemo(() => { + const list = productions?.productions ?? []; + return list.find( + (p) => String(p.productionId) === String(selectedProductionId) + ); + }, [productions, selectedProductionId]); + + const lines = selectedProduction?.lines; + + useEffect(() => { + const list = productions?.productions ?? []; + if (!list.length) return; + if (!selectedProductionId) { + setValue("productionId", Number(list[0].productionId), { + shouldValidate: true, + shouldDirty: false, + }); + } + }, [productions, selectedProductionId, setValue]); + + useEffect(() => { + if (lines && lines[0]) { + setValue("lineId", Number(lines[0].id), { + shouldValidate: true, + shouldDirty: false, + }); + } + }, [lines, setValue]); + + return ( + <> + Add SRT to WHIP transmitter + + + + Label(Optional) + + + + + + Production + {productions?.productions?.length ? ( + Number(v), + })} + > + {productions.productions.map((p) => ( + + ))} + + ) : ( + No productions available + )} + + + + Line + Number(v), + })} + disabled={!selectedProductionId || !lines} + > + {!lines ? ( + + ) : ( + lines.map((line: TLine) => ( + + )) + )} + + + + + WHIP Username + + + + + SRT Port + + + + + + SRT Restream URL (Optional) + + + + + + SRT Mode + + {...srtModeOptions.map((opt) => ( + + ))} + + + + +
+ + SRT Url + + mode !== "caller" || + (v && v.length > 0) || + "SRT URL is required in Caller mode", + })} + disabled={mode !== "caller"} + tabIndex={mode === "caller" ? 0 : -1} + /> + +
+
+ + + + Add Transmitter + {loading && ( + + + + )} + + +
+ + ); +}; diff --git a/src/components/io-bridge-page/io-bridge-components.tsx b/src/components/io-bridge-page/io-bridge-components.tsx new file mode 100644 index 00000000..1a9fdbea --- /dev/null +++ b/src/components/io-bridge-page/io-bridge-components.tsx @@ -0,0 +1,158 @@ +import styled from "@emotion/styled"; +import { PrimaryButton } from "../form-elements/form-elements"; + +export const HeaderWrapper = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding-right: 1rem; +`; + +export const HeaderText = styled.div` + font-size: 2rem; + font-weight: bold; + margin-right: 0.5rem; + + .production-name-container { + display: inline-block; + width: 100%; + } +`; + +export const Text = styled.p` + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 1rem; + font-weight: bold; + font-size: 1.5rem; + font-weight: 300; + line-height: 3.2rem; +`; + +export const Wrapper = styled.div` + display: flex; + gap: 2rem; + margin-top: 1rem; +`; + +export const ListWrapper = styled.div` + display: flex; + flex-wrap: wrap; + padding: 0; + align-items: flex-start; +`; + +export const FormWrapper = styled.div` + display: flex; + flex-direction: column; + width: 40rem; +`; + +export const StatusIndicator = styled.div<{ bgColor: string }>` + width: 1rem; + height: 1rem; + border-radius: 50%; + background-color: ${({ bgColor }) => bgColor}; + margin-right: 1rem; +`; + +export const SubmitButton = styled(PrimaryButton)<{ + shouldSubmitOnEnter?: boolean; +}>` + outline: ${({ shouldSubmitOnEnter }) => + shouldSubmitOnEnter ? "2px solid #007bff" : "none"}; + outline-offset: ${({ shouldSubmitOnEnter }) => + shouldSubmitOnEnter ? "2px" : "0"}; +`; + +export const StateChangeButton = styled(PrimaryButton)<{ bgColor: string }>` + color: white; + background-color: ${({ bgColor }) => bgColor}; +`; + +export const StatusWrapper = styled.div` + display: flex; + flex-direction: row; + gap: 1rem; + align-items: center; + justify-content: space-between; +`; + +export const TextWrapper = styled.div` + display: flex; + flex-direction: column; +`; + +export const ContentWrapper = styled.div` + display: flex; + flex-direction: row; + margin-bottom: 1rem; + gap: 0.5rem; +`; + +export const ButtonContentWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; +`; + +export const IconWrapper = styled.div` + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + svg { + height: 3rem; + width: 3rem; + fill: white; + } +`; + +export const BoldHeader = styled.p` + font-weight: bold; + font-size: 2rem; + margin-bottom: 2rem; +`; + +export const BoldText = styled.p` + font-weight: bold; + margin-bottom: 0.5rem; +`; + +export const Collapsible = styled.div<{ open: boolean }>` + display: grid; + grid-template-rows: ${(p) => (p.open ? "1fr" : "0fr")}; + transition: + grid-template-rows 220ms ease, + opacity 220ms ease, + margin-top 220ms ease; + opacity: ${(p) => (p.open ? 1 : 0)}; + margin-top: ${(p) => (p.open ? "16px" : "0")}; + > div { + overflow: hidden; + } +`; + +export const FieldHeader = styled.div` + display: flex; + flex-direction: row; + gap: 1.5rem; +`; + +export const SectionBox = styled.div` + border: 1px solid #424242; + border-radius: 0.5rem; + padding: 2rem; + margin-bottom: 2rem; + background-color: transparent; +`; + +export const SectionHeader = styled.h2` + font-size: 1.5rem; + font-weight: bold; + margin: 0 0 1.5rem 0; + color: #fff; +`; diff --git a/src/components/io-bridge-page/io-bridge-page.tsx b/src/components/io-bridge-page/io-bridge-page.tsx new file mode 100644 index 00000000..73c7504d --- /dev/null +++ b/src/components/io-bridge-page/io-bridge-page.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from "react"; +import { useGlobalState } from "../../global-state/context-provider"; +import { useListTransmitters } from "../../hooks/use-transmitter-list"; +import { useListReceivers } from "../../hooks/use-receiver-list"; +import { useBridgeConfig } from "../../hooks/use-bridge-config"; +import { PageHeader } from "../page-layout/page-header"; +import { PrimaryButton } from "../form-elements/form-elements"; +import { LocalError } from "../error"; +import { ListWrapper, SectionBox, SectionHeader } from "./io-bridge-components"; +import { TransmitterItem } from "./transmitter-item"; +import { ReceiverItem } from "./receiver-item"; +import { AddTransmitterForm } from "./add-transmitter-modal/add-transmitter-form"; +import { AddReceiverForm } from "./add-receiver-modal/add-receiver-form"; +import { Modal } from "../modal/modal"; + +export const IOBridgePage = ({ setApiError }: { setApiError: () => void }) => { + const [showAddTransmitterModal, setShowAddTransmitterModal] = useState(false); + const [showAddReceiverModal, setShowAddReceiverModal] = useState(false); + const [{ apiError }] = useGlobalState(); + + const { config, loading: configLoading } = useBridgeConfig(); + + // Only fetch transmitters if WHIP gateway is enabled + const { transmitters, error, setIntervalLoad, refresh } = + useListTransmitters(config?.whipGatewayEnabled ?? false); + + // Only fetch receivers if WHEP gateway is enabled + const { + receivers, + error: receiverError, + setIntervalLoad: setReceiverIntervalLoad, + refresh: refreshReceivers, + } = useListReceivers(config?.whepGatewayEnabled ?? false); + + const list = Array.isArray(transmitters) ? transmitters : []; + const receiverList = Array.isArray(receivers) ? receivers : []; + + useEffect(() => { + if (apiError) setApiError(); + }, [apiError, setApiError]); + + // Only poll transmitters if WHIP gateway is enabled + useEffect(() => { + if (!config?.whipGatewayEnabled) return undefined; + const interval = window.setInterval( + () => setIntervalLoad((prev) => prev + 1), + 1000 + ); + return () => window.clearInterval(interval); + }, [setIntervalLoad, config?.whipGatewayEnabled]); + + // Only poll receivers if WHEP gateway is enabled + useEffect(() => { + if (!config?.whepGatewayEnabled) return undefined; + const interval = window.setInterval( + () => setReceiverIntervalLoad((prev) => prev + 1), + 1000 + ); + return () => window.clearInterval(interval); + }, [setReceiverIntervalLoad, config?.whepGatewayEnabled]); + + // If no gateways are configured, don't show the page + if (configLoading) { + return null; // or a loading spinner + } + + if (!config || (!config.whipGatewayEnabled && !config.whepGatewayEnabled)) { + return null; + } + + return ( + <> + +
+ {config.whipGatewayEnabled && ( + setShowAddTransmitterModal(true)} + > + Add SRT to WHIP transmitter + + )} + {config.whepGatewayEnabled && ( + setShowAddReceiverModal(true)} + > + Add WHEP to SRT receiver + + )} +
+
+ + {error && } + {receiverError && } + +
+ {config.whipGatewayEnabled && ( + + SRT to WHIP Transmitters + + {list.map((t) => ( + + ))} + {list.length === 0 &&

No transmitters configured

} +
+
+ )} + + {config.whepGatewayEnabled && ( + + WHEP to SRT Receivers + + {receiverList.map((r) => ( + + ))} + {receiverList.length === 0 &&

No receivers configured

} +
+
+ )} +
+ + {showAddTransmitterModal && ( + setShowAddTransmitterModal(false)}> + { + setShowAddTransmitterModal(false); + await refresh(); + }} + /> + + )} + + {showAddReceiverModal && ( + setShowAddReceiverModal(false)}> + { + setShowAddReceiverModal(false); + await refreshReceivers(); + }} + /> + + )} + + ); +}; diff --git a/src/components/io-bridge-page/receiver-expanded-content.tsx b/src/components/io-bridge-page/receiver-expanded-content.tsx new file mode 100644 index 00000000..26e0f409 --- /dev/null +++ b/src/components/io-bridge-page/receiver-expanded-content.tsx @@ -0,0 +1,351 @@ +import { useMemo, useState, useEffect } from "react"; +import { TSavedReceiver, TBridgeState } from "../../api/api"; +import { + ButtonsWrapper, + DeleteButton, + SpinnerWrapper, +} from "../delete-button/delete-button-components"; +import { Spinner } from "../loader/loader"; +import { ConfirmationModal } from "../verify-decision/confirmation-modal"; +import { useFetchProduction } from "../landing-page/use-fetch-production"; +import { useFetchProductionList } from "../landing-page/use-fetch-production-list"; +import { + BoldText, + ButtonContentWrapper, + ContentWrapper, + IconWrapper, + StateChangeButton, + TextWrapper, +} from "./io-bridge-components"; +import { StopIcon, PlayIcon, ChevronDownIcon, ChevronUpIcon, EditIcon, SaveIcon } from "../../assets/icons/icon"; +import { useToggleReceiver } from "../../hooks/use-edit-receiver"; +import { useUpdateReceiver } from "../../hooks/use-update-receiver"; +import { FormInput, FormSelect } from "../form-elements/form-elements"; +import { TLine } from "../production-line/types"; + +type ReceiverExpandedContentProps = { + receiver: TSavedReceiver; + displayConfirmationModal: boolean; + deleteReceiverLoading: boolean; + setDisplayConfirmationModal: (displayConfirmationModal: boolean) => void; + setRemoveReceiverId: (receiverId: string | null) => void; + refresh?: () => void; +}; + +export const ReceiverExpandedContent = ({ + receiver, + displayConfirmationModal, + deleteReceiverLoading, + setDisplayConfirmationModal, + setRemoveReceiverId, + refresh, +}: ReceiverExpandedContentProps) => { + const [showAllParameters, setShowAllParameters] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [editLabel, setEditLabel] = useState(receiver.label || ""); + const [editProductionId, setEditProductionId] = useState(receiver.productionId); + const [editLineId, setEditLineId] = useState(receiver.lineId); + + const isDeleteReceiverDisabled = receiver.status === "running"; + + const { toggle, loading: editReceiverLoading } = useToggleReceiver( + // eslint-disable-next-line no-underscore-dangle + receiver._id + ); + const { updateReceiver, loading: updateLoading } = useUpdateReceiver(); + const { productions } = useFetchProductionList({ extended: "true" }); + + const productionIdNum = receiver.productionId || null; + const lineIdStr = receiver.lineId?.toString() || ""; + + const { production } = useFetchProduction(productionIdNum); + + const line = useMemo( + () => + production && lineIdStr + ? production.lines.find((l) => l.id === lineIdStr) + : undefined, + [production, lineIdStr] + ); + + const selectedProduction = useMemo(() => { + const list = productions?.productions ?? []; + return list.find( + (p) => String(p.productionId) === String(editProductionId) + ); + }, [productions, editProductionId]); + + const availableLines = selectedProduction?.lines || []; + + useEffect(() => { + // Only update edit values when not in edit mode to prevent polling from overwriting user changes + if (!isEditMode) { + setEditLabel(receiver.label || ""); + setEditProductionId(receiver.productionId); + setEditLineId(receiver.lineId); + } + }, [receiver, isEditMode]); + + const handleSave = async () => { + // eslint-disable-next-line no-underscore-dangle + const updated = await updateReceiver({ + id: receiver._id, + label: editLabel, + productionId: editProductionId, + lineId: editLineId, + }); + + if (updated) { + setIsEditMode(false); + if (refresh) { + refresh(); + } + } + }; + + const handleCancel = () => { + setEditLabel(receiver.label || ""); + setEditProductionId(receiver.productionId); + setEditLineId(receiver.lineId); + setIsEditMode(false); + }; + + const renderStateChangeButtonColor = (status: TBridgeState) => { + switch (status) { + case "running": + return "#f96c6c"; + case "failed": + return "#ebca6a"; + case "stopped": + return "#22c55e"; + case "idle": + return "#22c55e"; + default: + return "#59cbe8"; + } + }; + + const renderButtonContent = (status: TBridgeState) => { + switch (status) { + case "running": + return ( + + + + +

Stop

+
+ ); + default: + return ( + + + + +

Run

+
+ ); + } + }; + + return ( + <> + + +
+ {isEditMode ? ( +
+
+ Label: + setEditLabel(e.target.value)} + placeholder="Label" + style={{ marginTop: "4px", width: "100%" }} + /> +
+
+ Production: + { + const newProdId = Number(e.target.value); + setEditProductionId(newProdId); + const newProd = productions?.productions?.find(p => String(p.productionId) === e.target.value); + if (newProd?.lines?.[0]) { + setEditLineId(Number(newProd.lines[0].id)); + } + }} + style={{ marginTop: "4px", width: "100%" }} + > + {productions?.productions?.map((p) => ( + + ))} + +
+
+ Line: + setEditLineId(Number(e.target.value))} + style={{ marginTop: "4px", width: "100%" }} + > + {availableLines.map((line: TLine) => ( + + ))} + +
+
+ + +
+
+ ) : ( +
+
+ + Label: {receiver.label || "N/A"} + + + Production: {production?.name} + + + Line: {line?.name} + +
+ +
+ )} +
+
+ +
+ {showAllParameters && ( +
+
ID: {receiver._id}
+
Label: {receiver.label || "N/A"}
+
Production ID: {receiver.productionId}
+
Line ID: {receiver.lineId}
+
WHEP URL: {receiver.whepUrl}
+
SRT URL: {receiver.srtUrl}
+
Status: {receiver.status}
+
Created: {receiver.createdAt ? new Date(receiver.createdAt).toLocaleString() : "N/A"}
+
Updated: {receiver.updatedAt ? new Date(receiver.updatedAt).toLocaleString() : "N/A"}
+
+ )} +
+
+ + toggle(receiver.status as TBridgeState)} + disabled={editReceiverLoading} + type="button" + bgColor={renderStateChangeButtonColor( + receiver.status as TBridgeState + )} + > + {renderButtonContent(receiver.status as TBridgeState)} + + setDisplayConfirmationModal(true)} + > + Delete Receiver + {deleteReceiverLoading && ( + + + + )} + + + {displayConfirmationModal && ( + setDisplayConfirmationModal(false)} + // eslint-disable-next-line no-underscore-dangle + onConfirm={() => setRemoveReceiverId(receiver._id)} + /> + )} + + ); +}; diff --git a/src/components/io-bridge-page/receiver-item.tsx b/src/components/io-bridge-page/receiver-item.tsx new file mode 100644 index 00000000..75c15d7e --- /dev/null +++ b/src/components/io-bridge-page/receiver-item.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import { TSavedReceiver, TBridgeState } from "../../api/api"; +import { CollapsibleItem } from "../shared/collapsible-item"; +import { ReceiverExpandedContent } from "./receiver-expanded-content"; +import { useDeleteReceiver } from "../../hooks/use-delete-receiver"; +import { + BoldText, + StatusIndicator, + StatusWrapper, +} from "./io-bridge-components"; + +type ReceiverItemProps = { + receiver: TSavedReceiver; + refresh: () => void; +}; + +export const ReceiverItem = ({ receiver, refresh }: ReceiverItemProps) => { + const [displayConfirmationModal, setDisplayConfirmationModal] = + useState(false); + const [removeReceiverId, setRemoveReceiverId] = useState(null); + + const { loading: deleteReceiverLoading, success: successfulDeleteReceiver } = + useDeleteReceiver(removeReceiverId); + + useEffect(() => { + if (successfulDeleteReceiver) { + setRemoveReceiverId(null); + setDisplayConfirmationModal(false); + refresh(); + } + }, [successfulDeleteReceiver, refresh]); + + const renderStatusIndicator = (status: TBridgeState) => { + switch (status) { + case "running": + return "#22c55e"; + case "idle": + return "#22c55e"; + case "stopped": + return "#ebca6a"; + case "failed": + return "#f96c6c"; + default: + return "#59cbe8"; + } + }; + + const headerContent = ( + <> + {receiver.label ?? "Receiver"} + + Status: {receiver.status as TBridgeState} + + + + ); + + const expandedContent = ( + + ); + + return ( + + ); +}; diff --git a/src/components/io-bridge-page/transmitter-expanded-content.tsx b/src/components/io-bridge-page/transmitter-expanded-content.tsx new file mode 100644 index 00000000..35148a7a --- /dev/null +++ b/src/components/io-bridge-page/transmitter-expanded-content.tsx @@ -0,0 +1,352 @@ +import { useMemo, useState, useEffect } from "react"; +import { TSavedTransmitter, TBridgeState } from "../../api/api"; +import { + ButtonsWrapper, + DeleteButton, + SpinnerWrapper, +} from "../delete-button/delete-button-components"; +import { Spinner } from "../loader/loader"; +import { ConfirmationModal } from "../verify-decision/confirmation-modal"; +import { useFetchProduction } from "../landing-page/use-fetch-production"; +import { useFetchProductionList } from "../landing-page/use-fetch-production-list"; +import { + BoldText, + ButtonContentWrapper, + ContentWrapper, + IconWrapper, + StateChangeButton, + TextWrapper, +} from "./io-bridge-components"; +import { StopIcon, PlayIcon, ChevronDownIcon, ChevronUpIcon, EditIcon, SaveIcon } from "../../assets/icons/icon"; +import { useToggleTransmitter } from "../../hooks/use-edit-transmitter"; +import { useUpdateTransmitter } from "../../hooks/use-update-transmitter"; +import { FormInput, FormSelect } from "../form-elements/form-elements"; +import { TLine } from "../production-line/types"; + +type ExpandedContentProps = { + transmitter: TSavedTransmitter; + displayConfirmationModal: boolean; + deleteTransmitterLoading: boolean; + setDisplayConfirmationModal: (displayConfirmationModal: boolean) => void; + setRemoveTransmitterPort: (transmitterPort: string | null) => void; + refresh?: () => void; +}; + +export const ExpandedContent = ({ + transmitter, + displayConfirmationModal, + deleteTransmitterLoading, + setDisplayConfirmationModal, + setRemoveTransmitterPort, + refresh, +}: ExpandedContentProps) => { + const [showAllParameters, setShowAllParameters] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [editLabel, setEditLabel] = useState(transmitter.label || ""); + const [editProductionId, setEditProductionId] = useState(transmitter.productionId); + const [editLineId, setEditLineId] = useState(transmitter.lineId); + + const isDeleteTransmitterDisabled = transmitter.status === "running"; + + const { toggle, loading: editTransmitterLoading } = useToggleTransmitter( + transmitter.port.toString() + ); + const { updateTransmitter, loading: updateLoading } = useUpdateTransmitter(); + const { productions } = useFetchProductionList({ extended: "true" }); + + const productionIdNum = transmitter.productionId || null; + const lineIdStr = transmitter.lineId?.toString() || ""; + + const { production } = useFetchProduction(productionIdNum); + + const line = useMemo( + () => + production && lineIdStr + ? production.lines.find((l) => l.id === lineIdStr) + : undefined, + [production, lineIdStr] + ); + + const selectedProduction = useMemo(() => { + const list = productions?.productions ?? []; + return list.find( + (p) => String(p.productionId) === String(editProductionId) + ); + }, [productions, editProductionId]); + + const availableLines = selectedProduction?.lines || []; + + useEffect(() => { + // Only update edit values when not in edit mode to prevent polling from overwriting user changes + if (!isEditMode) { + setEditLabel(transmitter.label || ""); + setEditProductionId(transmitter.productionId); + setEditLineId(transmitter.lineId); + } + }, [transmitter, isEditMode]); + + const handleSave = async () => { + const updated = await updateTransmitter({ + port: transmitter.port.toString(), + label: editLabel, + productionId: editProductionId, + lineId: editLineId, + }); + + if (updated) { + setIsEditMode(false); + if (refresh) { + refresh(); + } + } + }; + + const handleCancel = () => { + setEditLabel(transmitter.label || ""); + setEditProductionId(transmitter.productionId); + setEditLineId(transmitter.lineId); + setIsEditMode(false); + }; + + const renderStateChangeButtonColor = (status: TBridgeState) => { + switch (status) { + case "running": + return "#f96c6c"; + case "failed": + return "#ebca6a"; + case "stopped": + return "#22c55e"; + case "idle": + return "#22c55e"; + default: + return "#59cbe8"; + } + }; + + const renderButtonContent = (status: TBridgeState) => { + switch (status) { + case "running": + return ( + + + + +

Stop

+
+ ); + default: + return ( + + + + +

Run

+
+ ); + } + }; + + return ( + <> + + +
+ {isEditMode ? ( +
+
+ Label: + setEditLabel(e.target.value)} + placeholder="Label" + style={{ marginTop: "4px", width: "100%" }} + /> +
+
+ Production: + { + const newProdId = Number(e.target.value); + setEditProductionId(newProdId); + const newProd = productions?.productions?.find(p => String(p.productionId) === e.target.value); + if (newProd?.lines?.[0]) { + setEditLineId(Number(newProd.lines[0].id)); + } + }} + style={{ marginTop: "4px", width: "100%" }} + > + {productions?.productions?.map((p) => ( + + ))} + +
+
+ Line: + setEditLineId(Number(e.target.value))} + style={{ marginTop: "4px", width: "100%" }} + > + {availableLines.map((line: TLine) => ( + + ))} + +
+
+ + +
+
+ ) : ( +
+
+ + Label: {transmitter.label || "N/A"} + + + Production: {production?.name} + + + Line: {line?.name} + +
+ +
+ )} +
+
+ +
+ {showAllParameters && ( +
+
ID: {transmitter._id}
+
Label: {transmitter.label || "N/A"}
+
Port: {transmitter.port}
+
Production ID: {transmitter.productionId}
+
Line ID: {transmitter.lineId}
+
WHIP URL: {transmitter.whipUrl}
+
SRT URL: {transmitter.srtUrl || "N/A"}
+
Pass-through URL: {transmitter.passThroughUrl || "N/A"}
+
Mode: {transmitter.mode}
+
Status: {transmitter.status}
+
Created: {transmitter.createdAt ? new Date(transmitter.createdAt).toLocaleString() : "N/A"}
+
Updated: {transmitter.updatedAt ? new Date(transmitter.updatedAt).toLocaleString() : "N/A"}
+
+ )} +
+
+ + + {renderButtonContent(transmitter.status as TBridgeState)} + + setDisplayConfirmationModal(true)} + > + Delete Transmitter + {deleteTransmitterLoading && ( + + + + )} + + + {displayConfirmationModal && ( + setDisplayConfirmationModal(false)} + onConfirm={() => + setRemoveTransmitterPort(transmitter.port.toString()) + } + /> + )} + + ); +}; diff --git a/src/components/io-bridge-page/transmitter-item.tsx b/src/components/io-bridge-page/transmitter-item.tsx new file mode 100644 index 00000000..3c90f61b --- /dev/null +++ b/src/components/io-bridge-page/transmitter-item.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { TSavedTransmitter, TBridgeState } from "../../api/api"; +import { CollapsibleItem } from "../shared/collapsible-item"; +import { ExpandedContent } from "./transmitter-expanded-content"; +import { useDeleteTransmitter } from "../../hooks/use-delete-transmitter"; +import { + BoldText, + StatusIndicator, + StatusWrapper, +} from "./io-bridge-components"; + +type TransmitterItemProps = { + transmitter: TSavedTransmitter; + refresh: () => void; +}; + +export const TransmitterItem = ({ + transmitter, + refresh, +}: TransmitterItemProps) => { + const [displayConfirmationModal, setDisplayConfirmationModal] = + useState(false); + const [removeTransmitterPort, setRemoveTransmitterPort] = useState< + string | null + >(null); + + const { + loading: deleteTransmitterLoading, + success: successfulDeleteTransmitter, + } = useDeleteTransmitter(removeTransmitterPort); + + useEffect(() => { + if (successfulDeleteTransmitter) { + setRemoveTransmitterPort(null); + setDisplayConfirmationModal(false); + refresh(); + } + }, [successfulDeleteTransmitter, refresh]); + + const renderStatusIndicator = (status: TBridgeState) => { + switch (status) { + case "running": + return "#22c55e"; + case "idle": + return "#22c55e"; + case "stopped": + return "#ebca6a"; + case "failed": + return "#f96c6c"; + default: + return "#59cbe8"; + } + }; + + const headerContent = ( + <> + {transmitter.label ?? "Label"} + + Status: {transmitter.status as TBridgeState} + + + + ); + + const expandedContent = ( + + ); + + return ( + + ); +}; diff --git a/src/components/landing-page/productions-list-container.tsx b/src/components/landing-page/productions-list-container.tsx index 8716fb2f..8ef8874a 100644 --- a/src/components/landing-page/productions-list-container.tsx +++ b/src/components/landing-page/productions-list-container.tsx @@ -66,14 +66,22 @@ export const ProductionsListContainer = () => { navigate("/manage-productions"); }; + const goToIO = () => { + navigate("/manage-io-bridge"); + }; + return ( <> {!isMobile && ( <> + + I/O Bridges + + {!!productions?.productions.length && ( - Manage + Productions )} diff --git a/src/hooks/use-bridge-config.tsx b/src/hooks/use-bridge-config.tsx new file mode 100644 index 00000000..714af498 --- /dev/null +++ b/src/hooks/use-bridge-config.tsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import { API, TBridgeConfig } from "../api/api"; + +export const useBridgeConfig = () => { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadConfig = async () => { + try { + const data = await API.fetchBridgeConfig(); + setConfig(data); + setError(null); + } catch (err) { + console.error("Failed to fetch bridge config:", err); + setError( + err instanceof Error ? err.message : "Failed to fetch bridge config" + ); + // If config endpoint fails, assume no gateways are available + setConfig({ whipGatewayEnabled: false, whepGatewayEnabled: false }); + } finally { + setLoading(false); + } + }; + + loadConfig(); + }, []); + + return { config, loading, error }; +}; diff --git a/src/hooks/use-create-receiver.tsx b/src/hooks/use-create-receiver.tsx new file mode 100644 index 00000000..75b8f718 --- /dev/null +++ b/src/hooks/use-create-receiver.tsx @@ -0,0 +1,39 @@ +import { useRequest } from "./use-request"; +import { API, TSavedReceiver, getBackendBaseUrl } from "../api/api"; + +type FormValues = { + label?: string; + productionId: number; + lineId: number; + whepUsername: string; + srtUrl: string; +}; + +export const useCreateReceiver = ({ + createReceiver, +}: { + createReceiver: FormValues | null; +}) => { + return useRequest< + { + label?: string; + productionId: number; + lineId: number; + whepUrl: string; + srtUrl: string; + }, + TSavedReceiver + >({ + params: createReceiver + ? { + label: createReceiver.label, + productionId: createReceiver.productionId, + lineId: createReceiver.lineId, + whepUrl: `${getBackendBaseUrl()}/api/v1/whep/${createReceiver.productionId}/${createReceiver.lineId}/${encodeURIComponent(createReceiver.whepUsername)}`, + srtUrl: createReceiver.srtUrl, + } + : null, + apiCall: API.createReceiver, + errorMessage: (t) => `Failed to create receiver: ${t.label || ""}`, + }); +}; diff --git a/src/hooks/use-create-transmitter.tsx b/src/hooks/use-create-transmitter.tsx new file mode 100644 index 00000000..0106ea0a --- /dev/null +++ b/src/hooks/use-create-transmitter.tsx @@ -0,0 +1,48 @@ +import { useRequest } from "./use-request"; +import { API, TSavedTransmitter, getBackendBaseUrl } from "../api/api"; + +type FormValues = { + label?: string; + productionId: number; + lineId: number; + whipUsername: string; + port: number; + passThroughUrl?: string; + mode: "caller" | "listener"; + srtUrl?: string; +}; + +export const useCreateTransmitter = ({ + createTransmitter, +}: { + createTransmitter: FormValues | null; +}) => { + return useRequest< + { + label?: string; + port: number; + productionId: number; + lineId: number; + whipUrl: string; + mode: "caller" | "listener"; + srtUrl?: string; + passThroughUrl?: string; + }, + TSavedTransmitter + >({ + params: createTransmitter + ? { + label: createTransmitter.label, + port: createTransmitter.port, + productionId: createTransmitter.productionId, + lineId: createTransmitter.lineId, + whipUrl: `${getBackendBaseUrl()}/api/v1/whip/${createTransmitter.productionId}/${createTransmitter.lineId}/${encodeURIComponent(createTransmitter.whipUsername)}`, + passThroughUrl: createTransmitter.passThroughUrl, + mode: createTransmitter.mode, + srtUrl: createTransmitter.srtUrl, + } + : null, + apiCall: API.createTransmitter, + errorMessage: (t) => `Failed to create transmitter: ${t.label || ""}`, + }); +}; diff --git a/src/hooks/use-delete-receiver.tsx b/src/hooks/use-delete-receiver.tsx new file mode 100644 index 00000000..98cc9d69 --- /dev/null +++ b/src/hooks/use-delete-receiver.tsx @@ -0,0 +1,10 @@ +import { API } from "../api/api"; +import { useRequest } from "./use-request"; + +export const useDeleteReceiver = (receiverId: string | null) => { + return useRequest({ + params: receiverId, + apiCall: API.deleteReceiver, + errorMessage: () => "Failed to delete receiver", + }); +}; diff --git a/src/hooks/use-delete-transmitter.tsx b/src/hooks/use-delete-transmitter.tsx new file mode 100644 index 00000000..4d73b563 --- /dev/null +++ b/src/hooks/use-delete-transmitter.tsx @@ -0,0 +1,10 @@ +import { API } from "../api/api"; +import { useRequest } from "./use-request"; + +export const useDeleteTransmitter = (port: string | null) => { + return useRequest({ + params: port, + apiCall: API.deleteTransmitter, + errorMessage: () => "Failed to delete transmitter", + }); +}; diff --git a/src/hooks/use-edit-receiver.tsx b/src/hooks/use-edit-receiver.tsx new file mode 100644 index 00000000..c3365347 --- /dev/null +++ b/src/hooks/use-edit-receiver.tsx @@ -0,0 +1,33 @@ +import { useState } from "react"; +import { API, TBridgeState } from "../api/api"; + +export const useToggleReceiver = (receiverId: string) => { + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + + const toggle = async (currentState: TBridgeState) => { + setLoading(true); + setError(null); + setSuccess(false); + + try { + const newState = + currentState === TBridgeState.RUNNING + ? TBridgeState.STOPPED + : TBridgeState.RUNNING; + + await API.updateReceiverState({ + id: receiverId, + state: newState, + }); + setSuccess(true); + } catch (e: unknown) { + setError(e as Error); + } finally { + setLoading(false); + } + }; + + return { toggle, loading, success, error }; +}; diff --git a/src/hooks/use-edit-transmitter.tsx b/src/hooks/use-edit-transmitter.tsx new file mode 100644 index 00000000..f6154556 --- /dev/null +++ b/src/hooks/use-edit-transmitter.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; +import { API, TEditTransmitter, TBridgeState } from "../api/api"; +import { useFetchTransmitter } from "./use-fetch-transmitter"; + +export const useToggleTransmitter = (port: string) => { + const { transmitter, refetch, setTransmitter } = useFetchTransmitter(port); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + + const toggle = async () => { + if (!transmitter) return; + + const next = + transmitter.status === TBridgeState.RUNNING + ? TBridgeState.STOPPED + : TBridgeState.RUNNING; + + const prev = transmitter; + setTransmitter((t) => (t ? { ...t, status: next } : t)); + + setLoading(true); + setError(null); + setSuccess(false); + + try { + const payload: TEditTransmitter = { port, state: next }; + await API.updateTransmitterState(payload); + setSuccess(true); + await refetch(); + } catch (e: unknown) { + setTransmitter(prev); + setError(e as Error); + } finally { + setLoading(false); + } + }; + + return { toggle, loading, success, error }; +}; diff --git a/src/hooks/use-fetch-transmitter.tsx b/src/hooks/use-fetch-transmitter.tsx new file mode 100644 index 00000000..dfaa8571 --- /dev/null +++ b/src/hooks/use-fetch-transmitter.tsx @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { API, TSavedTransmitter } from "../api/api"; + +type TUseFetchTransmitter = (port: string | null) => { + transmitter: TSavedTransmitter | null; + error: Error | null; + loading: boolean; + refetch: () => Promise; + setTransmitter: React.Dispatch< + React.SetStateAction + >; +}; + +export const useFetchTransmitter: TUseFetchTransmitter = (port) => { + const [transmitter, setTransmitter] = useState( + null + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const abortedRef = useRef(false); + + const doFetch = useCallback(async () => { + setLoading(true); + setError(null); + + if (!port) { + setTransmitter(null); + setLoading(false); + return; + } + + const localAbortedToken = { aborted: false }; + abortedRef.current = false; + + try { + const t = await API.fetchTransmitter(parseInt(port, 10)); + if (abortedRef.current || localAbortedToken.aborted) return; + setTransmitter(t); + } catch (e: unknown) { + if (abortedRef.current || localAbortedToken.aborted) return; + setTransmitter(null); + setError(e as Error); + } finally { + if (!abortedRef.current && !localAbortedToken.aborted) { + setLoading(false); + } + } + }, [port]); + + useEffect(() => { + abortedRef.current = false; + setTransmitter(null); + doFetch(); + return () => { + abortedRef.current = true; + }; + }, [doFetch]); + + return { + error, + transmitter, + loading, + refetch: doFetch, + setTransmitter, + }; +}; diff --git a/src/hooks/use-receiver-list.tsx b/src/hooks/use-receiver-list.tsx new file mode 100644 index 00000000..28679c5c --- /dev/null +++ b/src/hooks/use-receiver-list.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import { API, TSavedReceiver } from "../api/api"; + +export const useListReceivers = (enabled: boolean = true) => { + const [receivers, setReceivers] = useState([]); + const [intervalLoad, setIntervalLoad] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + // Don't fetch if disabled + if (!enabled) return undefined; + + let aborted = false; + setError(null); + + API.fetchReceiverList() + .then((result) => { + if (aborted) return; + setReceivers(result); + setError(null); + }) + .catch((e) => { + setError( + e instanceof Error ? e : new Error("Failed to fetch receivers") + ); + }); + + return () => { + aborted = true; + }; + }, [intervalLoad, enabled]); + + const refresh = async () => { + try { + const result = await API.fetchReceiverList(); + setReceivers(result); + setError(null); + } catch (e) { + setError(e instanceof Error ? e : new Error("Failed to fetch receivers")); + } + }; + + return { + receivers, + error, + setIntervalLoad, + refresh, + }; +}; diff --git a/src/hooks/use-transmitter-list.tsx b/src/hooks/use-transmitter-list.tsx new file mode 100644 index 00000000..d116f00e --- /dev/null +++ b/src/hooks/use-transmitter-list.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; +import { API, TSavedTransmitter } from "../api/api"; + +export const useListTransmitters = (enabled: boolean = true) => { + const [transmitters, setTransmitters] = useState([]); + const [intervalLoad, setIntervalLoad] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + // Don't fetch if disabled + if (!enabled) return undefined; + + let aborted = false; + setError(null); + + API.fetchTransmitterList() + .then((result) => { + if (aborted) return; + setTransmitters(result); + setError(null); + }) + .catch((e) => { + setError( + e instanceof Error ? e : new Error("Failed to fetch transmitters") + ); + }); + + return () => { + aborted = true; + }; + }, [intervalLoad, enabled]); + + const refresh = async () => { + try { + const result = await API.fetchTransmitterList(); + setTransmitters(result); + setError(null); + } catch (e) { + setError( + e instanceof Error ? e : new Error("Failed to fetch transmitters") + ); + } + }; + + return { + transmitters, + error, + setIntervalLoad, + refresh, + }; +}; diff --git a/src/hooks/use-update-receiver.tsx b/src/hooks/use-update-receiver.tsx new file mode 100644 index 00000000..280151c3 --- /dev/null +++ b/src/hooks/use-update-receiver.tsx @@ -0,0 +1,27 @@ +import { useState } from "react"; +import { API, TPatchReceiver, TSavedReceiver } from "../api/api"; + +export const useUpdateReceiver = () => { + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + + const updateReceiver = async (data: TPatchReceiver): Promise => { + setLoading(true); + setError(null); + setSuccess(false); + + try { + const updatedReceiver = await API.updateReceiver(data); + setSuccess(true); + return updatedReceiver; + } catch (e: unknown) { + setError(e as Error); + return null; + } finally { + setLoading(false); + } + }; + + return { updateReceiver, loading, success, error }; +}; diff --git a/src/hooks/use-update-transmitter.tsx b/src/hooks/use-update-transmitter.tsx new file mode 100644 index 00000000..f3fa4d5a --- /dev/null +++ b/src/hooks/use-update-transmitter.tsx @@ -0,0 +1,27 @@ +import { useState } from "react"; +import { API, TPatchTransmitter, TSavedTransmitter } from "../api/api"; + +export const useUpdateTransmitter = () => { + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + + const updateTransmitter = async (data: TPatchTransmitter): Promise => { + setLoading(true); + setError(null); + setSuccess(false); + + try { + const updatedTransmitter = await API.updateTransmitter(data); + setSuccess(true); + return updatedTransmitter; + } catch (e: unknown) { + setError(e as Error); + return null; + } finally { + setLoading(false); + } + }; + + return { updateTransmitter, loading, success, error }; +}; From c31096aff5c79282c13f7c4d26e02fab22d52709 Mon Sep 17 00:00:00 2001 From: Per Enstedt Date: Mon, 27 Oct 2025 13:03:00 +0100 Subject: [PATCH 2/5] chore: prettier --- .../add-receiver-modal/add-receiver-form.tsx | 9 +- .../io-bridge-page/io-bridge-page.tsx | 5 +- .../receiver-expanded-content.tsx | 129 ++++++++++++--- .../transmitter-expanded-content.tsx | 147 +++++++++++++++--- src/hooks/use-update-receiver.tsx | 4 +- src/hooks/use-update-transmitter.tsx | 4 +- 6 files changed, 250 insertions(+), 48 deletions(-) diff --git a/src/components/io-bridge-page/add-receiver-modal/add-receiver-form.tsx b/src/components/io-bridge-page/add-receiver-modal/add-receiver-form.tsx index 54951ab8..89e7dc2c 100644 --- a/src/components/io-bridge-page/add-receiver-modal/add-receiver-form.tsx +++ b/src/components/io-bridge-page/add-receiver-modal/add-receiver-form.tsx @@ -182,7 +182,14 @@ export const AddReceiverForm = ({ onSave }: AddReceiverFormProps) => { })} placeholder="srt://hostname:port" /> -
+
Default SRT mode is caller.

SRT Listener URL example: srt://0.0.0.0:10000?mode=listener
diff --git a/src/components/io-bridge-page/io-bridge-page.tsx b/src/components/io-bridge-page/io-bridge-page.tsx index 73c7504d..cea4dc1d 100644 --- a/src/components/io-bridge-page/io-bridge-page.tsx +++ b/src/components/io-bridge-page/io-bridge-page.tsx @@ -21,8 +21,9 @@ export const IOBridgePage = ({ setApiError }: { setApiError: () => void }) => { const { config, loading: configLoading } = useBridgeConfig(); // Only fetch transmitters if WHIP gateway is enabled - const { transmitters, error, setIntervalLoad, refresh } = - useListTransmitters(config?.whipGatewayEnabled ?? false); + const { transmitters, error, setIntervalLoad, refresh } = useListTransmitters( + config?.whipGatewayEnabled ?? false + ); // Only fetch receivers if WHEP gateway is enabled const { diff --git a/src/components/io-bridge-page/receiver-expanded-content.tsx b/src/components/io-bridge-page/receiver-expanded-content.tsx index 26e0f409..da284ccc 100644 --- a/src/components/io-bridge-page/receiver-expanded-content.tsx +++ b/src/components/io-bridge-page/receiver-expanded-content.tsx @@ -17,7 +17,14 @@ import { StateChangeButton, TextWrapper, } from "./io-bridge-components"; -import { StopIcon, PlayIcon, ChevronDownIcon, ChevronUpIcon, EditIcon, SaveIcon } from "../../assets/icons/icon"; +import { + StopIcon, + PlayIcon, + ChevronDownIcon, + ChevronUpIcon, + EditIcon, + SaveIcon, +} from "../../assets/icons/icon"; import { useToggleReceiver } from "../../hooks/use-edit-receiver"; import { useUpdateReceiver } from "../../hooks/use-update-receiver"; import { FormInput, FormSelect } from "../form-elements/form-elements"; @@ -43,7 +50,9 @@ export const ReceiverExpandedContent = ({ const [showAllParameters, setShowAllParameters] = useState(false); const [isEditMode, setIsEditMode] = useState(false); const [editLabel, setEditLabel] = useState(receiver.label || ""); - const [editProductionId, setEditProductionId] = useState(receiver.productionId); + const [editProductionId, setEditProductionId] = useState( + receiver.productionId + ); const [editLineId, setEditLineId] = useState(receiver.lineId); const isDeleteReceiverDisabled = receiver.status === "running"; @@ -171,7 +180,9 @@ export const ReceiverExpandedContent = ({ onChange={(e) => { const newProdId = Number(e.target.value); setEditProductionId(newProdId); - const newProd = productions?.productions?.find(p => String(p.productionId) === e.target.value); + const newProd = productions?.productions?.find( + (p) => String(p.productionId) === e.target.value + ); if (newProd?.lines?.[0]) { setEditLineId(Number(newProd.lines[0].id)); } @@ -179,7 +190,10 @@ export const ReceiverExpandedContent = ({ style={{ marginTop: "4px", width: "100%" }} > {productions?.productions?.map((p) => ( - ))} @@ -199,7 +213,14 @@ export const ReceiverExpandedContent = ({ ))}
-
+
) : ( -
+
Label: {receiver.label || "N/A"} @@ -266,7 +305,14 @@ export const ReceiverExpandedContent = ({ }} title="Edit receiver" > - + @@ -290,23 +336,66 @@ export const ReceiverExpandedContent = ({ width: "100%", }} > - + {showAllParameters ? : } {showAllParameters ? "Hide" : "Show more"}
{showAllParameters && ( -
-
ID: {receiver._id}
-
Label: {receiver.label || "N/A"}
-
Production ID: {receiver.productionId}
-
Line ID: {receiver.lineId}
-
WHEP URL: {receiver.whepUrl}
-
SRT URL: {receiver.srtUrl}
-
Status: {receiver.status}
-
Created: {receiver.createdAt ? new Date(receiver.createdAt).toLocaleString() : "N/A"}
-
Updated: {receiver.updatedAt ? new Date(receiver.updatedAt).toLocaleString() : "N/A"}
+
+
+ ID: {receiver._id} +
+
+ Label:{" "} + {receiver.label || "N/A"} +
+
+ Production ID:{" "} + {receiver.productionId} +
+
+ Line ID:{" "} + {receiver.lineId} +
+
+ WHEP URL:{" "} + + {receiver.whepUrl} + +
+
+ SRT URL:{" "} + + {receiver.srtUrl} + +
+
+ Status:{" "} + {receiver.status} +
+
+ Created:{" "} + {receiver.createdAt + ? new Date(receiver.createdAt).toLocaleString() + : "N/A"} +
+
+ Updated:{" "} + {receiver.updatedAt + ? new Date(receiver.updatedAt).toLocaleString() + : "N/A"} +
)} diff --git a/src/components/io-bridge-page/transmitter-expanded-content.tsx b/src/components/io-bridge-page/transmitter-expanded-content.tsx index 35148a7a..ac0893f0 100644 --- a/src/components/io-bridge-page/transmitter-expanded-content.tsx +++ b/src/components/io-bridge-page/transmitter-expanded-content.tsx @@ -17,7 +17,14 @@ import { StateChangeButton, TextWrapper, } from "./io-bridge-components"; -import { StopIcon, PlayIcon, ChevronDownIcon, ChevronUpIcon, EditIcon, SaveIcon } from "../../assets/icons/icon"; +import { + StopIcon, + PlayIcon, + ChevronDownIcon, + ChevronUpIcon, + EditIcon, + SaveIcon, +} from "../../assets/icons/icon"; import { useToggleTransmitter } from "../../hooks/use-edit-transmitter"; import { useUpdateTransmitter } from "../../hooks/use-update-transmitter"; import { FormInput, FormSelect } from "../form-elements/form-elements"; @@ -43,7 +50,9 @@ export const ExpandedContent = ({ const [showAllParameters, setShowAllParameters] = useState(false); const [isEditMode, setIsEditMode] = useState(false); const [editLabel, setEditLabel] = useState(transmitter.label || ""); - const [editProductionId, setEditProductionId] = useState(transmitter.productionId); + const [editProductionId, setEditProductionId] = useState( + transmitter.productionId + ); const [editLineId, setEditLineId] = useState(transmitter.lineId); const isDeleteTransmitterDisabled = transmitter.status === "running"; @@ -169,7 +178,9 @@ export const ExpandedContent = ({ onChange={(e) => { const newProdId = Number(e.target.value); setEditProductionId(newProdId); - const newProd = productions?.productions?.find(p => String(p.productionId) === e.target.value); + const newProd = productions?.productions?.find( + (p) => String(p.productionId) === e.target.value + ); if (newProd?.lines?.[0]) { setEditLineId(Number(newProd.lines[0].id)); } @@ -177,7 +188,10 @@ export const ExpandedContent = ({ style={{ marginTop: "4px", width: "100%" }} > {productions?.productions?.map((p) => ( - ))} @@ -197,7 +211,14 @@ export const ExpandedContent = ({ ))}
-
+
) : ( -
+
Label: {transmitter.label || "N/A"} @@ -264,7 +303,14 @@ export const ExpandedContent = ({ }} title="Edit transmitter" > - + @@ -288,26 +334,81 @@ export const ExpandedContent = ({ width: "100%", }} > - + {showAllParameters ? : } {showAllParameters ? "Hide" : "Show more"}
{showAllParameters && ( -
-
ID: {transmitter._id}
-
Label: {transmitter.label || "N/A"}
-
Port: {transmitter.port}
-
Production ID: {transmitter.productionId}
-
Line ID: {transmitter.lineId}
-
WHIP URL: {transmitter.whipUrl}
-
SRT URL: {transmitter.srtUrl || "N/A"}
-
Pass-through URL: {transmitter.passThroughUrl || "N/A"}
-
Mode: {transmitter.mode}
-
Status: {transmitter.status}
-
Created: {transmitter.createdAt ? new Date(transmitter.createdAt).toLocaleString() : "N/A"}
-
Updated: {transmitter.updatedAt ? new Date(transmitter.updatedAt).toLocaleString() : "N/A"}
+
+
+ ID:{" "} + {transmitter._id} +
+
+ Label:{" "} + {transmitter.label || "N/A"} +
+
+ Port:{" "} + {transmitter.port} +
+
+ Production ID:{" "} + {transmitter.productionId} +
+
+ Line ID:{" "} + {transmitter.lineId} +
+
+ WHIP URL:{" "} + + {transmitter.whipUrl} + +
+
+ SRT URL:{" "} + + {transmitter.srtUrl || "N/A"} + +
+
+ Pass-through URL:{" "} + + {transmitter.passThroughUrl || "N/A"} + +
+
+ Mode:{" "} + {transmitter.mode} +
+
+ Status:{" "} + {transmitter.status} +
+
+ Created:{" "} + {transmitter.createdAt + ? new Date(transmitter.createdAt).toLocaleString() + : "N/A"} +
+
+ Updated:{" "} + {transmitter.updatedAt + ? new Date(transmitter.updatedAt).toLocaleString() + : "N/A"} +
)} diff --git a/src/hooks/use-update-receiver.tsx b/src/hooks/use-update-receiver.tsx index 280151c3..260875cc 100644 --- a/src/hooks/use-update-receiver.tsx +++ b/src/hooks/use-update-receiver.tsx @@ -6,7 +6,9 @@ export const useUpdateReceiver = () => { const [success, setSuccess] = useState(false); const [error, setError] = useState(null); - const updateReceiver = async (data: TPatchReceiver): Promise => { + const updateReceiver = async ( + data: TPatchReceiver + ): Promise => { setLoading(true); setError(null); setSuccess(false); diff --git a/src/hooks/use-update-transmitter.tsx b/src/hooks/use-update-transmitter.tsx index f3fa4d5a..947927af 100644 --- a/src/hooks/use-update-transmitter.tsx +++ b/src/hooks/use-update-transmitter.tsx @@ -6,7 +6,9 @@ export const useUpdateTransmitter = () => { const [success, setSuccess] = useState(false); const [error, setError] = useState(null); - const updateTransmitter = async (data: TPatchTransmitter): Promise => { + const updateTransmitter = async ( + data: TPatchTransmitter + ): Promise => { setLoading(true); setError(null); setSuccess(false); From bc5891276473472f9c2fe497ff73d48ffe987a0d Mon Sep 17 00:00:00 2001 From: Per Enstedt Date: Mon, 27 Oct 2025 16:24:51 +0100 Subject: [PATCH 3/5] fix: show bridge I/O button conditional --- .../landing-page/productions-list-container.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/components/landing-page/productions-list-container.tsx b/src/components/landing-page/productions-list-container.tsx index 8ef8874a..a5f683a2 100644 --- a/src/components/landing-page/productions-list-container.tsx +++ b/src/components/landing-page/productions-list-container.tsx @@ -9,6 +9,7 @@ import { PageHeader } from "../page-layout/page-header.tsx"; import { AddIcon, EditIcon } from "../../assets/icons/icon.tsx"; import { PrimaryButton } from "../form-elements/form-elements"; import { isMobile } from "../../bowser.ts"; +import { useBridgeConfig } from "../../hooks/use-bridge-config.tsx"; const HeaderButton = styled(PrimaryButton)` margin-left: 1rem; @@ -43,6 +44,8 @@ export const ProductionsListContainer = () => { extended: "true", }); + const { config } = useBridgeConfig(); + const showRefreshing = useRefreshAnimation({ reloadProductionList, doInitialLoad, @@ -70,15 +73,21 @@ export const ProductionsListContainer = () => { navigate("/manage-io-bridge"); }; + // Show I/O Bridges button only if at least one gateway is enabled + const showIOBridgeButton = + config?.whipGatewayEnabled || config?.whepGatewayEnabled; + return ( <> {!isMobile && ( <> - - I/O Bridges - - + {showIOBridgeButton && ( + + I/O Bridges + + + )} {!!productions?.productions.length && ( Productions From 740756ee81727c17e091c8b47b8ac1c7ef1379ab Mon Sep 17 00:00:00 2001 From: Per Enstedt Date: Tue, 11 Nov 2025 11:13:53 +0100 Subject: [PATCH 4/5] fix: index tx on id --- src/api/api.ts | 16 ++-- .../add-transmitter-form.tsx | 81 ++++++++++++------- .../transmitter-expanded-content.tsx | 10 +-- .../io-bridge-page/transmitter-item.tsx | 8 +- src/hooks/use-delete-transmitter.tsx | 4 +- src/hooks/use-edit-transmitter.tsx | 6 +- src/hooks/use-fetch-transmitter.tsx | 10 +-- 7 files changed, 80 insertions(+), 55 deletions(-) diff --git a/src/api/api.ts b/src/api/api.ts index 9c150d68..ccdc23b9 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -80,7 +80,7 @@ export enum TBridgeState { } export type TEditTransmitter = { - port: string; + id: string; state: TBridgeState; }; @@ -90,7 +90,7 @@ export type TEditReceiver = { }; export type TPatchTransmitter = { - port: string; + id: string; label?: string; productionId?: number; lineId?: number; @@ -409,9 +409,9 @@ export const API = { }, }) ).then((res) => res?.transmitters || []), - fetchTransmitter: (port: number): Promise => + fetchTransmitter: (id: string): Promise => handleFetchRequest( - fetch(`${API_URL}bridge/tx/${port}`, { + fetch(`${API_URL}bridge/tx/${id}`, { method: "GET", headers: { ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), @@ -420,7 +420,7 @@ export const API = { ), updateTransmitterState: async (data: TEditTransmitter) => handleFetchRequest( - fetch(`${API_URL}bridge/tx/${data.port}/state`, { + fetch(`${API_URL}bridge/tx/${data.id}/state`, { method: "PUT", headers: { "Content-Type": "application/json", @@ -433,7 +433,7 @@ export const API = { ), updateTransmitter: async (data: TPatchTransmitter) => handleFetchRequest( - fetch(`${API_URL}bridge/tx/${data.port}`, { + fetch(`${API_URL}bridge/tx/${data.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", @@ -446,9 +446,9 @@ export const API = { }), }) ), - deleteTransmitter: async (port: string): Promise => + deleteTransmitter: async (id: string): Promise => handleFetchRequest( - fetch(`${API_URL}bridge/tx/${port}`, { + fetch(`${API_URL}bridge/tx/${id}`, { method: "DELETE", headers: { ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), diff --git a/src/components/io-bridge-page/add-transmitter-modal/add-transmitter-form.tsx b/src/components/io-bridge-page/add-transmitter-modal/add-transmitter-form.tsx index caa7f233..02761f64 100644 --- a/src/components/io-bridge-page/add-transmitter-modal/add-transmitter-form.tsx +++ b/src/components/io-bridge-page/add-transmitter-modal/add-transmitter-form.tsx @@ -60,9 +60,14 @@ export const AddTransmitterForm = ({ onSave }: AddTransmitterFormProps) => { const mode = useWatch({ control, name: "mode" }); useEffect(() => { - if (mode !== "caller") { + if (mode === "caller") { + setValue("port", 0); + clearErrors("port"); + clearErrors("srtUrl"); + } else { setValue("srtUrl", ""); clearErrors("srtUrl"); + clearErrors("port"); } }, [mode, setValue, clearErrors]); @@ -190,29 +195,6 @@ export const AddTransmitterForm = ({ onSave }: AddTransmitterFormProps) => { /> - - SRT Port - - - - - - SRT Restream URL (Optional) - - - - SRT Mode { required: "SRT Mode", minLength: 1, })} - defaultValue="Listener" + defaultValue="listener" > - {...srtModeOptions.map((opt) => ( + {srtModeOptions.map((opt) => ( @@ -231,18 +213,51 @@ export const AddTransmitterForm = ({ onSave }: AddTransmitterFormProps) => { + +
+ + SRT Port (Local) + + mode !== "listener" || + (v && v > 0) || + "SRT Port is required in Listener mode", + valueAsNumber: true, + })} + placeholder="SRT Port" + disabled={mode !== "listener"} + tabIndex={mode === "listener" ? 0 : -1} + /> + +
+
+
- SRT Url + SRT Destination URL mode !== "caller" || (v && v.length > 0) || - "SRT URL is required in Caller mode", + "SRT URL is required in Caller mode (e.g., srt://host:port)", + setValueAs: (v) => { + // Add srt:// prefix if user didn't specify it + if (v && !v.startsWith("srt://")) { + return `srt://${v}`; + } + return v; + }, })} + placeholder="srt://host:port" disabled={mode !== "caller"} tabIndex={mode === "caller" ? 0 : -1} /> @@ -250,6 +265,16 @@ export const AddTransmitterForm = ({ onSave }: AddTransmitterFormProps) => {
+ + + SRT Restream URL (Optional) + + + + void; - setRemoveTransmitterPort: (transmitterPort: string | null) => void; + setRemoveTransmitterId: (transmitterId: string | null) => void; refresh?: () => void; }; @@ -44,7 +44,7 @@ export const ExpandedContent = ({ displayConfirmationModal, deleteTransmitterLoading, setDisplayConfirmationModal, - setRemoveTransmitterPort, + setRemoveTransmitterId, refresh, }: ExpandedContentProps) => { const [showAllParameters, setShowAllParameters] = useState(false); @@ -58,7 +58,7 @@ export const ExpandedContent = ({ const isDeleteTransmitterDisabled = transmitter.status === "running"; const { toggle, loading: editTransmitterLoading } = useToggleTransmitter( - transmitter.port.toString() + transmitter._id ); const { updateTransmitter, loading: updateLoading } = useUpdateTransmitter(); const { productions } = useFetchProductionList({ extended: "true" }); @@ -96,7 +96,7 @@ export const ExpandedContent = ({ const handleSave = async () => { const updated = await updateTransmitter({ - port: transmitter.port.toString(), + id: transmitter._id, label: editLabel, productionId: editProductionId, lineId: editLineId, @@ -444,7 +444,7 @@ export const ExpandedContent = ({ confirmationText="Are you sure?" onCancel={() => setDisplayConfirmationModal(false)} onConfirm={() => - setRemoveTransmitterPort(transmitter.port.toString()) + setRemoveTransmitterId(transmitter._id) } /> )} diff --git a/src/components/io-bridge-page/transmitter-item.tsx b/src/components/io-bridge-page/transmitter-item.tsx index 3c90f61b..d7541d19 100644 --- a/src/components/io-bridge-page/transmitter-item.tsx +++ b/src/components/io-bridge-page/transmitter-item.tsx @@ -20,18 +20,18 @@ export const TransmitterItem = ({ }: TransmitterItemProps) => { const [displayConfirmationModal, setDisplayConfirmationModal] = useState(false); - const [removeTransmitterPort, setRemoveTransmitterPort] = useState< + const [removeTransmitterId, setRemoveTransmitterId] = useState< string | null >(null); const { loading: deleteTransmitterLoading, success: successfulDeleteTransmitter, - } = useDeleteTransmitter(removeTransmitterPort); + } = useDeleteTransmitter(removeTransmitterId); useEffect(() => { if (successfulDeleteTransmitter) { - setRemoveTransmitterPort(null); + setRemoveTransmitterId(null); setDisplayConfirmationModal(false); refresh(); } @@ -69,7 +69,7 @@ export const TransmitterItem = ({ transmitter={transmitter} displayConfirmationModal={displayConfirmationModal} setDisplayConfirmationModal={setDisplayConfirmationModal} - setRemoveTransmitterPort={setRemoveTransmitterPort} + setRemoveTransmitterId={setRemoveTransmitterId} deleteTransmitterLoading={deleteTransmitterLoading} refresh={refresh} /> diff --git a/src/hooks/use-delete-transmitter.tsx b/src/hooks/use-delete-transmitter.tsx index 4d73b563..eb63c651 100644 --- a/src/hooks/use-delete-transmitter.tsx +++ b/src/hooks/use-delete-transmitter.tsx @@ -1,9 +1,9 @@ import { API } from "../api/api"; import { useRequest } from "./use-request"; -export const useDeleteTransmitter = (port: string | null) => { +export const useDeleteTransmitter = (id: string | null) => { return useRequest({ - params: port, + params: id, apiCall: API.deleteTransmitter, errorMessage: () => "Failed to delete transmitter", }); diff --git a/src/hooks/use-edit-transmitter.tsx b/src/hooks/use-edit-transmitter.tsx index f6154556..ac2c54bd 100644 --- a/src/hooks/use-edit-transmitter.tsx +++ b/src/hooks/use-edit-transmitter.tsx @@ -2,8 +2,8 @@ import { useState } from "react"; import { API, TEditTransmitter, TBridgeState } from "../api/api"; import { useFetchTransmitter } from "./use-fetch-transmitter"; -export const useToggleTransmitter = (port: string) => { - const { transmitter, refetch, setTransmitter } = useFetchTransmitter(port); +export const useToggleTransmitter = (id: string) => { + const { transmitter, refetch, setTransmitter } = useFetchTransmitter(id); const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(false); const [error, setError] = useState(null); @@ -24,7 +24,7 @@ export const useToggleTransmitter = (port: string) => { setSuccess(false); try { - const payload: TEditTransmitter = { port, state: next }; + const payload: TEditTransmitter = { id, state: next }; await API.updateTransmitterState(payload); setSuccess(true); await refetch(); diff --git a/src/hooks/use-fetch-transmitter.tsx b/src/hooks/use-fetch-transmitter.tsx index dfaa8571..e9a0c490 100644 --- a/src/hooks/use-fetch-transmitter.tsx +++ b/src/hooks/use-fetch-transmitter.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { API, TSavedTransmitter } from "../api/api"; -type TUseFetchTransmitter = (port: string | null) => { +type TUseFetchTransmitter = (id: string | null) => { transmitter: TSavedTransmitter | null; error: Error | null; loading: boolean; @@ -11,7 +11,7 @@ type TUseFetchTransmitter = (port: string | null) => { >; }; -export const useFetchTransmitter: TUseFetchTransmitter = (port) => { +export const useFetchTransmitter: TUseFetchTransmitter = (id) => { const [transmitter, setTransmitter] = useState( null ); @@ -24,7 +24,7 @@ export const useFetchTransmitter: TUseFetchTransmitter = (port) => { setLoading(true); setError(null); - if (!port) { + if (!id) { setTransmitter(null); setLoading(false); return; @@ -34,7 +34,7 @@ export const useFetchTransmitter: TUseFetchTransmitter = (port) => { abortedRef.current = false; try { - const t = await API.fetchTransmitter(parseInt(port, 10)); + const t = await API.fetchTransmitter(id); if (abortedRef.current || localAbortedToken.aborted) return; setTransmitter(t); } catch (e: unknown) { @@ -46,7 +46,7 @@ export const useFetchTransmitter: TUseFetchTransmitter = (port) => { setLoading(false); } } - }, [port]); + }, [id]); useEffect(() => { abortedRef.current = false; From 1befa3623aa7dd861cb3a938cda2da26bf56fd66 Mon Sep 17 00:00:00 2001 From: Per Enstedt Date: Tue, 11 Nov 2025 11:29:36 +0100 Subject: [PATCH 5/5] chore: pretty --- .../io-bridge-page/transmitter-expanded-content.tsx | 4 +--- src/components/io-bridge-page/transmitter-item.tsx | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/io-bridge-page/transmitter-expanded-content.tsx b/src/components/io-bridge-page/transmitter-expanded-content.tsx index f9630a2b..70b9c591 100644 --- a/src/components/io-bridge-page/transmitter-expanded-content.tsx +++ b/src/components/io-bridge-page/transmitter-expanded-content.tsx @@ -443,9 +443,7 @@ export const ExpandedContent = ({ description={`You are about to delete the transmitter ${transmitter.label ?? `running at port ${transmitter.port}`}`} confirmationText="Are you sure?" onCancel={() => setDisplayConfirmationModal(false)} - onConfirm={() => - setRemoveTransmitterId(transmitter._id) - } + onConfirm={() => setRemoveTransmitterId(transmitter._id)} /> )} diff --git a/src/components/io-bridge-page/transmitter-item.tsx b/src/components/io-bridge-page/transmitter-item.tsx index d7541d19..de28238a 100644 --- a/src/components/io-bridge-page/transmitter-item.tsx +++ b/src/components/io-bridge-page/transmitter-item.tsx @@ -20,9 +20,9 @@ export const TransmitterItem = ({ }: TransmitterItemProps) => { const [displayConfirmationModal, setDisplayConfirmationModal] = useState(false); - const [removeTransmitterId, setRemoveTransmitterId] = useState< - string | null - >(null); + const [removeTransmitterId, setRemoveTransmitterId] = useState( + null + ); const { loading: deleteTransmitterLoading,