diff --git a/README.md b/README.md index 9af2dc71..19485fb7 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ LOGGER LEVELS `npm run dev` to start a dev server +### IO Bridge + +The frontend includes an IO bridge management page at `/manage-io-bridge` for creating and managing SRT-to-WebRTC transmitters and WebRTC-to-SRT receivers. The page is accessible via a button on the landing page when the backend has at least one gateway configured (`WHIP_GATEWAY_URL` or `WHEP_GATEWAY_URL`). + +The bridge configuration is fetched from the backend's `/api/v1/bridge/config` endpoint — no additional frontend environment variables are needed. + ### Preview diff --git a/src/App.tsx b/src/App.tsx index 264e07f1..02fa485e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,7 @@ import { CreateProductionPage } from "./components/create-production/create-prod import { useSetupTokenRefresh } from "./hooks/use-reauth.tsx"; import { TUserSettings } from "./components/user-settings/types"; import { PresetProvider } from "./contexts/preset-context.tsx"; +import { IOBridgePage } from "./components/io-bridge-page/io-bridge-page.tsx"; const DisplayBoxPositioningContainer = styled(FlexContainer)` justify-content: center; @@ -171,6 +172,13 @@ const AppContent = ({ } errorElement={} /> + setApiError(true)} /> + } + errorElement={} + /> } diff --git a/src/api/api.ts b/src/api/api.ts index 0b40619f..823aded9 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,10 +1,28 @@ import { handleFetchRequest } from "./handle-fetch-request.ts"; +// Type for runtime config injected by entrypoint +declare global { + interface Window { + ENV?: { + VITE_BACKEND_URL?: string; + VITE_BACKEND_API_VERSION?: string; + VITE_AUTH_URL?: string; + }; + } +} + 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_KEY = import.meta.env.VITE_BACKEND_API_KEY; +const BACKEND_URL = + window.ENV?.VITE_BACKEND_URL || import.meta.env.VITE_BACKEND_URL; + +// Helper to get backend base URL for constructing WHIP/WHEP URLs +export const getBackendBaseUrl = (): string => { + return BACKEND_URL ? BACKEND_URL.replace(/\/+$/, "") : window.location.origin; +}; export type TPresetCall = { productionId: string; @@ -50,6 +68,77 @@ 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; + noVideo?: boolean; + vp8?: boolean; + bypassVideo?: boolean; + 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 = { + id: string; + state: TBridgeState; +}; + +export type TEditReceiver = { + id: string; + state: TBridgeState; +}; + +export type TPatchTransmitter = { + id: 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; @@ -309,6 +398,171 @@ export const API = { }) ); }, + + fetchBridgeConfig: (): Promise => + handleFetchRequest( + fetch(`${API_URL}bridge/config`, { + method: "GET", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), + + // Transmitter (Bridge TX) endpoints + createTransmitter: async (data: { + label?: string; + port: number; + productionId: number; + lineId: number; + whipUrl: string; + passThroughUrl?: string; + mode: "caller" | "listener"; + srtUrl?: string; + noVideo?: boolean; + vp8?: boolean; + bypassVideo?: boolean; + }) => + 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: (id: string): Promise => + handleFetchRequest( + fetch(`${API_URL}bridge/tx/${id}`, { + method: "GET", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), + updateTransmitterState: async (data: TEditTransmitter) => + handleFetchRequest( + fetch(`${API_URL}bridge/tx/${data.id}/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.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, + }), + }) + ), + deleteTransmitter: async (id: string): Promise => + handleFetchRequest( + fetch(`${API_URL}bridge/tx/${id}`, { + method: "DELETE", + headers: { + ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), + }, + }) + ), + + // Receiver (Bridge RX) endpoints + 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}` } : {}), + }, + }) + ), + createPreset: (options: { name: string; calls: TPresetCall[]; diff --git a/src/assets/icons/delete.svg b/src/assets/icons/delete.svg new file mode 100644 index 00000000..1f68f1bd --- /dev/null +++ b/src/assets/icons/delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/icon.tsx b/src/assets/icons/icon.tsx index 26aeeec1..5e376673 100644 --- a/src/assets/icons/icon.tsx +++ b/src/assets/icons/icon.tsx @@ -25,6 +25,9 @@ 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 DeleteSvg from "./delete.svg?react"; +import Play from "./play.svg?react"; +import Stop from "./stop.svg?react"; export const MicMuted = () => ; @@ -79,3 +82,9 @@ export const SaveIcon = () => ; export const HelpIcon = () => ; export const WarningIcon = () => ; + +export const PlayIcon = () => ; + +export const StopIcon = () => ; + +export const DeleteIcon = () => ; diff --git a/src/assets/icons/play.svg b/src/assets/icons/play.svg new file mode 100644 index 00000000..adde7a35 --- /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..2fee8a63 --- /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..e92f3c60 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: 2rem; `; export const DeleteButton = styled(SecondaryButton)` @@ -12,10 +13,19 @@ export const DeleteButton = styled(SecondaryButton)` align-items: center; background: #d15c5c; color: white; + font-size: 1.3rem; + padding: 0.6rem 1.2rem; + line-height: 1.8rem; &:disabled { background: #ab5252; } + + svg { + width: 1.6rem; + height: 1.6rem; + fill: white; + } `; export const SpinnerWrapper = styled.div` 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..33a6563c --- /dev/null +++ b/src/components/io-bridge-page/add-receiver-modal/add-receiver-form.tsx @@ -0,0 +1,218 @@ +import styled from "@emotion/styled"; +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; +}; + +const SrtUrlHelp = styled.div` + margin-top: 4px; + font-size: 12px; + color: #999; + line-height: 1.4; +`; + +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) => ( + + {p.name} + + ))} + + ) : ( + No productions available + )} + + + + Line + Number(v), + })} + disabled={!selectedProductionId || !lines} + > + {!lines ? ( + There are no lines in this production + ) : ( + lines.map((line: TLine) => ( + + {line.name ?? line.name ?? `Line ${line.id ?? line.id}`} + + )) + )} + + + + + WHEP Username + + + + + SRT Output URL + + + Caller example: + srt://host:port?mode=caller&latency=200000&streamid=%23%21%3A%3Ar%3Dnp07%2Cm%3Dpublish + + Listener 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..0207ff2c --- /dev/null +++ b/src/components/io-bridge-page/add-transmitter-modal/add-transmitter-form.tsx @@ -0,0 +1,296 @@ +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("port", 0); + clearErrors("port"); + clearErrors("srtUrl"); + } else { + setValue("srtUrl", ""); + clearErrors("srtUrl"); + clearErrors("port"); + } + }, [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) => ( + + {p.name} + + ))} + + ) : ( + No productions available + )} + + + + Line + Number(v), + })} + disabled={!selectedProductionId || !lines} + > + {!lines ? ( + There are no lines in this production + ) : ( + lines.map((line: TLine) => ( + + {line.name ?? line.name ?? `Line ${line.id ?? line.id}`} + + )) + )} + + + + + WHIP Username + + + + + SRT Mode + + {srtModeOptions.map((opt) => ( + + {opt.charAt(0).toUpperCase() + opt.slice(1)} + + ))} + + + + + + + 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 Destination URL + + mode !== "caller" || + (v && v.length > 0) || + "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?latency=200000&streamid=..." + disabled={mode !== "caller"} + tabIndex={mode === "caller" ? 0 : -1} + /> + + + + + + + SRT Restream URL (Optional) + + + + + + + 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..f670eca5 --- /dev/null +++ b/src/components/io-bridge-page/io-bridge-components.tsx @@ -0,0 +1,318 @@ +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 StatusPill = styled.span<{ bgColor: string }>` + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.4rem 0.9rem; + border-radius: 0.4rem; + background-color: ${({ bgColor }) => + `color-mix(in srgb, ${bgColor} 22%, transparent)`}; + color: ${({ bgColor }) => bgColor}; + font-weight: 600; + font-size: 1.3rem; + line-height: 1; +`; + +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}; + font-size: 1.3rem; + padding: 0.6rem 1.2rem; + line-height: 1.8rem; +`; + +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 ButtonContentWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 1rem; + + p { + margin: 0; + } +`; + +export const IconWrapper = styled.div` + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + svg { + height: 1.8rem; + width: 1.8rem; + 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 RegularText = styled.span` + font-weight: normal; +`; + +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 TransmitterContainer = styled.div` + padding: 2rem; +`; + +export const PropsCard = styled.div` + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 1.6rem; + row-gap: 0.6rem; + align-items: baseline; + margin-top: 1.2rem; + font-size: 1.3rem; + line-height: 1.5; + padding: 1.6rem; + border-radius: 0.6rem; + background-color: rgba(255, 255, 255, 0.05); + margin-bottom: 1rem; + width: 100%; +`; + +export const PropKey = styled.span` + font-weight: bold; +`; + +export const PropValue = styled.span` + word-break: break-all; +`; + +export const EditableValue = styled.div` + display: flex; + align-items: center; + gap: 0.8rem; + word-break: break-all; +`; + +export const IconEditButton = styled.button` + background: none; + border: none; + padding: 0; + color: #59cbe8; + cursor: pointer; + display: inline-flex; + align-items: center; + + svg { + width: 1.4rem; + height: 1.4rem; + fill: #59cbe8; + } +`; + +export const InlineToggleButton = styled.button` + background: none; + border: none; + padding: 0; + color: #59cbe8; + cursor: pointer; + display: inline-flex; + align-items: center; + font-size: 1.3rem; + font-weight: 500; + + &:hover { + color: #7ad6ee; + } + + svg { + width: 1.4rem; + height: 1.4rem; + fill: #59cbe8; + } +`; + +export const InlineCopyButton = styled.button<{ copied?: boolean }>` + background: none; + border: none; + padding: 0; + color: ${({ copied }) => (copied ? "#22c55e" : "#59cbe8")}; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 1.3rem; + font-weight: 500; + transition: color 0.2s ease; + + svg { + width: 1.4rem; + height: 1.4rem; + fill: ${({ copied }) => (copied ? "#22c55e" : "#59cbe8")}; + transition: + fill 0.2s ease, + transform 0.2s ease; + transform: ${({ copied }) => (copied ? "scale(1.15)" : "scale(1)")}; + } +`; + +export const EditButton = styled.button` + background: none; + border: none; + border-radius: 0.4rem; + padding: 0.6rem 0.8rem; + color: white; + cursor: pointer; + display: flex; + align-items: center; + margin-left: 0.12rem; +`; + +export const SaveButton = styled.button<{ updateLoading: boolean }>` + background: #22c55e; + border: none; + border-radius: 0.4rem; + padding: 0.6rem 0.8rem; + color: white; + display: flex; + align-items: center; + gap: 0.4rem; + cursor: ${({ updateLoading }) => (updateLoading ? "not-allowed" : "pointer")}; +`; + +export const CancelButton = styled.button` + background: #f96c6c; + border: none; + border-radius: 0.4rem; + padding: 0.6rem 0.8rem; + color: white; + cursor: pointer; + font-size: 1.2rem; +`; + +export const ExpandedContentIconWrapper = styled.span` + width: 1.6rem; + height: 1.6rem; + display: flex; + align-items: center; +`; + +export const ButtonWrapper = styled.div` + display: flex; + justify-content: flex-end; + gap: 0.8rem; + margin-top: 1.2rem; +`; + +export const ContentContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + width: 100%; +`; + +export const HeaderContent = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 1.5rem; + + p { + margin: 0; + } +`; diff --git a/src/components/io-bridge-page/io-bridge-expanded-content.tsx b/src/components/io-bridge-page/io-bridge-expanded-content.tsx new file mode 100644 index 00000000..a40a0337 --- /dev/null +++ b/src/components/io-bridge-page/io-bridge-expanded-content.tsx @@ -0,0 +1,213 @@ +import { ReactNode, useEffect, useMemo, useState } from "react"; +import { TBridgeState } from "../../api/api"; +import { DeleteIcon, PlayIcon, StopIcon } from "../../assets/icons/icon"; +import { + ButtonsWrapper, + DeleteButton, + SpinnerWrapper, +} from "../delete-button/delete-button-components"; +import { useFetchProduction } from "../landing-page/use-fetch-production"; +import { useFetchProductionList } from "../landing-page/use-fetch-production-list"; +import { Spinner } from "../loader/loader"; +import { ConfirmationModal } from "../verify-decision/confirmation-modal"; +import { + ButtonContentWrapper, + IconWrapper, + StateChangeButton, +} from "./io-bridge-components"; +import { + IOBridgeItemBase, + IOBridgePropsList, + TEditableField, + UrlConfig, +} from "./io-bridge-props-list"; + +export type IOBridgeUpdatePayload = { + id: string; + label: string; + productionId: number; + lineId: number; +}; + +type IOBridgeExpandedContentProps = { + item: IOBridgeItemBase; + typeName: string; + urlConfig: UrlConfig; + extraRows?: ReactNode; + toggleLoading: boolean; + deleteLoading: boolean; + displayConfirmationModal: boolean; + onToggle: () => void; + onUpdate: (payload: IOBridgeUpdatePayload) => Promise; + setDisplayConfirmationModal: (display: boolean) => void; + setRemoveItemId: (id: string | null) => void; + refresh?: () => void; +}; + +const getStateChangeButtonColor = (status: TBridgeState) => { + switch (status) { + case "running": + return "#f96c6c"; + case "failed": + return "#ebca6a"; + case "stopped": + return "#22c55e"; + case "idle": + return "#22c55e"; + default: + return "#59cbe8"; + } +}; + +const renderToggleButtonContent = (status: TBridgeState) => { + if (status === "running") { + return ( + + + + + Stop + + ); + } + return ( + + + + + Run + + ); +}; + +export const IOBridgeExpandedContent = ({ + item, + typeName, + urlConfig, + extraRows, + toggleLoading, + deleteLoading, + displayConfirmationModal, + onToggle, + onUpdate, + setDisplayConfirmationModal, + setRemoveItemId, + refresh, +}: IOBridgeExpandedContentProps) => { + const [editingField, setEditingField] = useState(null); + const [editLabel, setEditLabel] = useState(item.label || ""); + const [editProductionId, setEditProductionId] = useState(item.productionId); + const [editLineId, setEditLineId] = useState(item.lineId); + + const isDeleteDisabled = item.status === "running"; + + const { productions } = useFetchProductionList({ extended: "true" }); + const productionIdNum = item.productionId || null; + const lineIdStr = item.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(() => { + // Don't sync from polling while a field is being edited, or in-progress edits get wiped + if (editingField === null) { + setEditLabel(item.label || ""); + setEditProductionId(item.productionId); + setEditLineId(item.lineId); + } + }, [item, editingField]); + + const handleSave = async () => { + const updated = await onUpdate({ + // eslint-disable-next-line no-underscore-dangle + id: item._id, + label: editLabel, + productionId: editProductionId, + lineId: editLineId, + }); + + if (updated) { + setEditingField(null); + if (refresh) refresh(); + } + }; + + const handleCancel = () => { + setEditLabel(item.label || ""); + setEditProductionId(item.productionId); + setEditLineId(item.lineId); + setEditingField(null); + }; + + return ( + <> + + + + {renderToggleButtonContent(item.status as TBridgeState)} + + setDisplayConfirmationModal(true)} + > + + {deleteLoading && ( + + + + )} + + + {displayConfirmationModal && ( + setDisplayConfirmationModal(false)} + // eslint-disable-next-line no-underscore-dangle + onConfirm={() => setRemoveItemId(item._id)} + /> + )} + > + ); +}; 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..801ae9b6 --- /dev/null +++ b/src/components/io-bridge-page/io-bridge-page.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState } from "react"; +import { useGlobalState } from "../../global-state/context-provider"; +import { PageHeader } from "../page-layout/page-header"; +import { PrimaryButton } from "../form-elements/form-elements"; +import { LocalError } from "../error"; +import { ListWrapper, TransmitterContainer } 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"; +import { Tabs } from "../tabs/tabs"; +import { useBridgeConfig } from "../../hooks/use-bridge-config"; +import { useListReceivers } from "../../hooks/use-receiver-list"; +import { useListTransmitters } from "../../hooks/use-transmitter-list"; + +const IO_TABS = [ + { id: "transmitters", label: "Transmitters" }, + { id: "receivers", label: "Receivers" }, +]; + +export const IOBridgePage = ({ setApiError }: { setApiError: () => void }) => { + const [showAddTransmitterModal, setShowAddTransmitterModal] = useState(false); + const [showAddReceiverModal, setShowAddReceiverModal] = useState(false); + const [activeTab, setActiveTab] = useState("transmitters"); + 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 && } + + + + {activeTab === "transmitters" && config.whipGatewayEnabled && ( + + + {list.map((t) => ( + + ))} + {list.length === 0 && No transmitters configured} + + + )} + + {activeTab === "receivers" && config.whepGatewayEnabled && ( + + + {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/io-bridge-props-list.tsx b/src/components/io-bridge-page/io-bridge-props-list.tsx new file mode 100644 index 00000000..fc1cada1 --- /dev/null +++ b/src/components/io-bridge-page/io-bridge-props-list.tsx @@ -0,0 +1,252 @@ +import { ReactNode, useState } from "react"; +import { TBasicProductionResponse } from "../../api/api"; +import { + CheckIcon, + ChevronUpIcon, + CopyIcon, + EditIcon, +} from "../../assets/icons/icon"; +import { useCopyLinks } from "../copy-button/use-copy-links"; +import { FormInput, FormSelect } from "../form-elements/form-elements"; +import { TLine } from "../production-line/types"; +import { + EditableValue, + IconEditButton, + InlineCopyButton, + InlineToggleButton, + PropKey, + PropsCard, + PropValue, +} from "./io-bridge-components"; + +const TRUNCATE_LENGTH = 15; + +export const TruncatedValue = ({ text }: { text?: string }) => { + const [expanded, setExpanded] = useState(false); + if (!text) return N/A; + + if (text.length <= TRUNCATE_LENGTH) { + return {text}; + } + + return ( + + {expanded ? text : `${text.slice(0, TRUNCATE_LENGTH)}...`} + setExpanded(!expanded)} + title={expanded ? "Show less" : "Show more"} + > + {expanded ? : "Show more"} + + + ); +}; + +export type TEditableField = "label" | "production" | "line" | null; + +export type IOBridgeItemBase = { + _id: string; + label?: string; + productionId: number; + lineId: number; + status: string; + srtUrl?: string; + createdAt?: string; + updatedAt?: string; +}; + +export type UrlConfig = { + label: string; + url: string; + copyLabel: string; +}; + +type IOBridgePropsListProps = { + item: IOBridgeItemBase; + editingField: TEditableField; + productionName: string; + lineName: string; + productions: TBasicProductionResponse[]; + availableLines: TLine[]; + editLabel: string; + editProductionId: number; + editLineId: number; + urlConfig: UrlConfig; + extraRows?: ReactNode; + setEditLabel: (label: string) => void; + setEditProductionId: (id: number) => void; + setEditLineId: (id: number) => void; + setEditingField: (field: TEditableField) => void; + onSave: () => void; + onCancel: () => void; +}; + +export const IOBridgePropsList = ({ + item, + editingField, + productionName, + lineName, + productions, + availableLines, + editLabel, + editProductionId, + editLineId, + urlConfig, + extraRows, + setEditLabel, + setEditProductionId, + setEditLineId, + onSave, + onCancel, + setEditingField, +}: IOBridgePropsListProps) => { + const { isCopied, handleCopyUrlToClipboard } = useCopyLinks(); + + const commit = () => { + const labelChanged = editLabel !== (item.label || ""); + const productionChanged = editProductionId !== item.productionId; + const lineChanged = editLineId !== item.lineId; + if (labelChanged || productionChanged || lineChanged) { + onSave(); + } else { + onCancel(); + } + }; + + return ( + + ID: + {/* eslint-disable-next-line no-underscore-dangle */} + {item._id} + + Label: + {editingField === "label" ? ( + setEditLabel(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commit(); + } + }} + placeholder="Label" + /> + ) : ( + + {item.label || "N/A"} + setEditingField("label")} + title="Edit label" + > + + + + )} + + Production: + {editingField === "production" ? ( + { + const newProdId = Number(e.target.value); + setEditProductionId(newProdId); + const newProd = productions.find( + (p) => String(p.productionId) === e.target.value + ); + if (newProd?.lines?.[0]) { + setEditLineId(Number(newProd.lines[0].id)); + } + }} + onBlur={commit} + > + {productions.map((p) => ( + + {p.name} + + ))} + + ) : ( + + {productionName} + setEditingField("production")} + title="Edit production" + > + + + + )} + + Line: + {editingField === "line" ? ( + setEditLineId(Number(e.target.value))} + onBlur={commit} + > + {availableLines.map((line) => ( + + {line.name} + + ))} + + ) : ( + + {lineName} + setEditingField("line")} + title="Edit line" + > + + + + )} + + Production ID: + {item.productionId} + + Line ID: + {item.lineId} + + {urlConfig.label} + + {urlConfig.url.split("/").pop() || "N/A"} + handleCopyUrlToClipboard(urlConfig.url)} + > + {isCopied ? : } + {isCopied ? "Copied!" : urlConfig.copyLabel} + + + + SRT URL: + + + {extraRows} + + Status: + {item.status} + + Created: + + {item.createdAt ? new Date(item.createdAt).toLocaleString() : "N/A"} + + + Updated: + + {item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "N/A"} + + + ); +}; 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..ac703069 --- /dev/null +++ b/src/components/io-bridge-page/receiver-expanded-content.tsx @@ -0,0 +1,46 @@ +import { TBridgeState, TSavedReceiver } from "../../api/api"; +import { useToggleReceiver } from "../../hooks/use-edit-receiver"; +import { useUpdateReceiver } from "../../hooks/use-update-receiver"; +import { IOBridgeExpandedContent } from "./io-bridge-expanded-content"; + +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) => { + // eslint-disable-next-line no-underscore-dangle + const { toggle, loading: toggleLoading } = useToggleReceiver(receiver._id); + const { updateReceiver } = useUpdateReceiver(); + + return ( + toggle(receiver.status as TBridgeState)} + toggleLoading={toggleLoading} + onUpdate={updateReceiver} + deleteLoading={deleteReceiverLoading} + displayConfirmationModal={displayConfirmationModal} + setDisplayConfirmationModal={setDisplayConfirmationModal} + setRemoveItemId={setRemoveReceiverId} + refresh={refresh} + /> + ); +}; 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..d586f8f4 --- /dev/null +++ b/src/components/io-bridge-page/receiver-item.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from "react"; +import { TSavedReceiver, TBridgeState } from "../../api/api"; +import { CollapsibleItem } from "../shared/collapsible-item"; +import { ReceiverExpandedContent } from "./receiver-expanded-content"; +import { BoldText, HeaderContent, StatusPill } from "./io-bridge-components"; +import { useDeleteReceiver } from "../../hooks/use-delete-receiver"; + +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 getStatusColor = (status: TBridgeState) => { + switch (status) { + case "running": + return "#22c55e"; + case "idle": + return "#59cbe8"; + case "stopped": + return "#ebca6a"; + case "failed": + return "#f96c6c"; + default: + return "#59cbe8"; + } + }; + + const headerContent = ( + + {receiver.label !== "" ? receiver.label : "Receiver"} + + {receiver.status.charAt(0).toUpperCase()} + {receiver.status.slice(1)} + + + ); + + 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..24b3bcc6 --- /dev/null +++ b/src/components/io-bridge-page/transmitter-expanded-content.tsx @@ -0,0 +1,65 @@ +import { TSavedTransmitter } from "../../api/api"; +import { useToggleTransmitter } from "../../hooks/use-edit-transmitter"; +import { useUpdateTransmitter } from "../../hooks/use-update-transmitter"; +import { IOBridgeExpandedContent } from "./io-bridge-expanded-content"; +import { PropKey, PropValue } from "./io-bridge-components"; +import { TruncatedValue } from "./io-bridge-props-list"; + +type ExpandedContentProps = { + transmitter: TSavedTransmitter; + displayConfirmationModal: boolean; + deleteTransmitterLoading: boolean; + setDisplayConfirmationModal: (displayConfirmationModal: boolean) => void; + setRemoveTransmitterId: (transmitterId: string | null) => void; + refresh?: () => void; +}; + +export const ExpandedContent = ({ + transmitter, + displayConfirmationModal, + deleteTransmitterLoading, + setDisplayConfirmationModal, + setRemoveTransmitterId, + refresh, +}: ExpandedContentProps) => { + /* eslint-disable no-underscore-dangle */ + const { toggle, loading: toggleLoading } = useToggleTransmitter( + transmitter._id + ); + /* eslint-enable no-underscore-dangle */ + const { updateTransmitter } = useUpdateTransmitter(); + + const extraRows = ( + <> + Port: + {transmitter.port} + + Pass-through URL: + + + Mode: + {transmitter.mode} + > + ); + + return ( + + ); +}; 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..4ce827ba --- /dev/null +++ b/src/components/io-bridge-page/transmitter-item.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from "react"; +import { TSavedTransmitter, TBridgeState } from "../../api/api"; +import { CollapsibleItem } from "../shared/collapsible-item"; +import { ExpandedContent } from "./transmitter-expanded-content"; +import { BoldText, HeaderContent, StatusPill } from "./io-bridge-components"; +import { useDeleteTransmitter } from "../../hooks/use-delete-transmitter"; + +type TransmitterItemProps = { + transmitter: TSavedTransmitter; + refresh: () => void; +}; + +export const TransmitterItem = ({ + transmitter, + refresh, +}: TransmitterItemProps) => { + const [displayConfirmationModal, setDisplayConfirmationModal] = + useState(false); + const [removeTransmitterId, setRemoveTransmitterId] = useState( + null + ); + + const { + loading: deleteTransmitterLoading, + success: successfulDeleteTransmitter, + } = useDeleteTransmitter(removeTransmitterId); + + useEffect(() => { + if (successfulDeleteTransmitter) { + setRemoveTransmitterId(null); + setDisplayConfirmationModal(false); + refresh(); + } + }, [successfulDeleteTransmitter, refresh]); + + const getStatusColor = (status: TBridgeState) => { + switch (status) { + case "running": + return "#22c55e"; + case "idle": + return "#59cbe8"; + case "stopped": + return "#ebca6a"; + case "failed": + return "#f96c6c"; + default: + return "#59cbe8"; + } + }; + + const headerContent = ( + + + {transmitter.label !== "" ? transmitter.label : transmitter.port} + + + {transmitter.status.charAt(0).toUpperCase()} + {transmitter.status.slice(1)} + + + ); + + const expandedContent = ( + + ); + + return ( + + ); +}; diff --git a/src/components/landing-page/productions-list-container.tsx b/src/components/landing-page/productions-list-container.tsx index 98873d8b..9fb22ea5 100644 --- a/src/components/landing-page/productions-list-container.tsx +++ b/src/components/landing-page/productions-list-container.tsx @@ -11,6 +11,7 @@ import { PrimaryButton } from "../form-elements/form-elements"; import { HideOnSmallScreen } from "../generic-components"; import { PresetList } from "./presets-list"; import { InfoTooltip } from "../info-tooltip/info-tooltip"; +import { useBridgeConfig } from "../../hooks/use-bridge-config.tsx"; const HeaderButton = styled(PrimaryButton)` margin-left: 1rem; @@ -85,6 +86,10 @@ export const ProductionsListContainer = () => { const { productions, doInitialLoad, error, setIntervalLoad } = useFetchProductionList(PRODUCTION_LIST_FILTER); + const { config: bridgeConfig } = useBridgeConfig(); + const bridgeEnabled = + bridgeConfig?.whipGatewayEnabled || bridgeConfig?.whepGatewayEnabled; + const showRefreshing = useRefreshAnimation({ reloadProductionList, doInitialLoad, @@ -130,18 +135,26 @@ export const ProductionsListContainer = () => { } > - {!!productions?.productions.length && ( - - - Manage + + {bridgeEnabled && ( + navigate("/manage-io-bridge")}> + I/O Bridges - - Create - - - - )} + )} + {!!productions?.productions.length && ( + <> + + Manage + + + + Create + + + > + )} + {productions && !productions.productions.length && ( diff --git a/src/components/tabs/tabs.tsx b/src/components/tabs/tabs.tsx new file mode 100644 index 00000000..2c4a5bac --- /dev/null +++ b/src/components/tabs/tabs.tsx @@ -0,0 +1,71 @@ +import styled from "@emotion/styled"; + +type TTab = { + id: string; + label: string; +}; + +type TTabsProps = { + tabs: TTab[]; + activeTab: string; + onChange: (id: string) => void; +}; + +const TabsWrapper = styled.div` + display: flex; + border-bottom: 0.1rem solid #424242; + margin-bottom: 2rem; +`; + +const TabButton = styled.button<{ active: boolean }>` + background: none; + border: none; + padding: 1rem 2rem; + font-size: 1.6rem; + font-weight: ${({ active }) => (active ? "bold" : "normal")}; + color: ${({ active }) => (active ? "rgba(89, 203, 232, 1)" : "#bdbdbd")}; + cursor: pointer; + position: relative; + transition: + color 0.2s ease, + border-color 0.2s ease; + + &::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -0.1rem; + height: 0.2rem; + background-color: ${({ active }) => + active ? "rgba(89, 203, 232, 1)" : "transparent"}; + transition: background-color 0.2s ease; + } + + &:hover:not(:disabled) { + color: rgba(89, 203, 232, 1); + } + + &:focus-visible { + outline: 0.2rem solid rgba(89, 203, 232, 1); + outline-offset: 0.2rem; + border-radius: 0.2rem; + } +`; + +export const Tabs = ({ tabs, activeTab, onChange }: TTabsProps) => ( + + {tabs.map((tab) => ( + onChange(tab.id)} + > + {tab.label} + + ))} + +); 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..efe02d89 --- /dev/null +++ b/src/hooks/use-create-transmitter.tsx @@ -0,0 +1,57 @@ +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; + noVideo?: boolean; + vp8?: boolean; + bypassVideo?: boolean; +}; + +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; + noVideo?: boolean; + vp8?: boolean; + bypassVideo?: boolean; + }, + 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, + noVideo: createTransmitter.noVideo, + vp8: createTransmitter.vp8, + bypassVideo: createTransmitter.bypassVideo, + } + : 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..eb63c651 --- /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 = (id: string | null) => { + return useRequest({ + params: id, + 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..ac2c54bd --- /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 = (id: string) => { + const { transmitter, refetch, setTransmitter } = useFetchTransmitter(id); + 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 = { id, 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..e9a0c490 --- /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 = (id: string | null) => { + transmitter: TSavedTransmitter | null; + error: Error | null; + loading: boolean; + refetch: () => Promise; + setTransmitter: React.Dispatch< + React.SetStateAction + >; +}; + +export const useFetchTransmitter: TUseFetchTransmitter = (id) => { + 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 (!id) { + setTransmitter(null); + setLoading(false); + return; + } + + const localAbortedToken = { aborted: false }; + abortedRef.current = false; + + try { + const t = await API.fetchTransmitter(id); + 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); + } + } + }, [id]); + + 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 copy.tsx b/src/hooks/use-transmitter-list copy.tsx new file mode 100644 index 00000000..d116f00e --- /dev/null +++ b/src/hooks/use-transmitter-list copy.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-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..260875cc --- /dev/null +++ b/src/hooks/use-update-receiver.tsx @@ -0,0 +1,29 @@ +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..947927af --- /dev/null +++ b/src/hooks/use-update-transmitter.tsx @@ -0,0 +1,29 @@ +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 }; +};
Stop
Run
No transmitters configured
No receivers configured