Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<img alt="Production list" src="https://github.com/user-attachments/assets/25bbcff9-5175-4fae-9eed-07fa9c166d6b" />
Expand Down
8 changes: 8 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -171,6 +172,13 @@ const AppContent = ({
}
errorElement={<ErrorPage />}
/>
<Route
path="/manage-io-bridge"
element={
<IOBridgePage setApiError={() => setApiError(true)} />
}
errorElement={<ErrorPage />}
/>
<Route
path="/production-lines/production/:productionId/line/:lineId"
element={<CallsPage />}
Expand Down
254 changes: 254 additions & 0 deletions src/api/api.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -309,6 +398,171 @@ export const API = {
})
);
},

fetchBridgeConfig: (): Promise<TBridgeConfig> =>
handleFetchRequest<TBridgeConfig>(
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<TSavedTransmitter>(
fetch(`${API_URL}bridge/tx`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}),
},
body: JSON.stringify(data),
})
),
fetchTransmitterList: (): Promise<TSavedTransmitter[]> =>
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<TSavedTransmitter> =>
handleFetchRequest<TSavedTransmitter>(
fetch(`${API_URL}bridge/tx/${id}`, {
method: "GET",
headers: {
...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}),
},
})
),
updateTransmitterState: async (data: TEditTransmitter) =>
handleFetchRequest<TSavedTransmitter>(
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<TSavedTransmitter>(
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<string> =>
handleFetchRequest<string>(
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<TSavedReceiver>(
fetch(`${API_URL}bridge/rx`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}),
},
body: JSON.stringify(data),
})
),
fetchReceiverList: (): Promise<TSavedReceiver[]> =>
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<TSavedReceiver> =>
handleFetchRequest<TSavedReceiver>(
fetch(`${API_URL}bridge/rx/${id}`, {
method: "GET",
headers: {
...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}),
},
})
),
updateReceiverState: async (data: TEditReceiver) =>
handleFetchRequest<TSavedReceiver>(
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<TSavedReceiver>(
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<string> =>
handleFetchRequest<string>(
fetch(`${API_URL}bridge/rx/${id}`, {
method: "DELETE",
headers: {
...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}),
},
})
),

createPreset: (options: {
name: string;
calls: TPresetCall[];
Expand Down
1 change: 1 addition & 0 deletions src/assets/icons/delete.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions src/assets/icons/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => <MicMute />;

Expand Down Expand Up @@ -79,3 +82,9 @@ export const SaveIcon = () => <Save />;
export const HelpIcon = () => <Help />;

export const WarningIcon = () => <Warning />;

export const PlayIcon = () => <Play />;

export const StopIcon = () => <Stop />;

export const DeleteIcon = () => <DeleteSvg />;
3 changes: 3 additions & 0 deletions src/assets/icons/play.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/icons/stop.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions src/components/delete-button/delete-button-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,27 @@ export const ButtonsWrapper = styled.div`
display: flex;
justify-content: flex-end;
margin: 1rem 0 1rem 0;
gap: 2rem;
`;

export const DeleteButton = styled(SecondaryButton)`
display: flex;
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`
Expand Down
Loading
Loading