+ {/* Header */}
+
+ {roomName}
+
+
+
+
+
+
+
+ {/* Main content */}
+
+ {/* Peer grid */}
+
+
Conference
+
+
+ {remotePeers.map((peer) => (
+
+ ))}
+
+
+
+ {/* WHEP preview */}
+
+
+
+ );
+}
diff --git a/conference-to-stream/web/src/components/JoinForm.tsx b/conference-to-stream/web/src/components/JoinForm.tsx
new file mode 100644
index 0000000..91a4f2e
--- /dev/null
+++ b/conference-to-stream/web/src/components/JoinForm.tsx
@@ -0,0 +1,86 @@
+import { useConnection, useInitializeDevices } from "@fishjam-cloud/react-client";
+import { useEffect, useState } from "react";
+import { createPeer, createRoom } from "../api";
+
+type Props = {
+ initialRoomName?: string;
+ onJoined: (result: { whepUrl: string; roomName: string; peerName: string }) => void;
+};
+
+export function JoinForm({ initialRoomName, onJoined }: Props) {
+ const { joinRoom } = useConnection();
+ const { initializeDevices } = useInitializeDevices();
+ const [roomName, setRoomName] = useState(initialRoomName ?? "");
+ const [peerName, setPeerName] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ initializeDevices({ enableVideo: true, enableAudio: true });
+ }, [initializeDevices]);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!roomName.trim() || !peerName.trim()) {
+ setError("Please fill in both fields.");
+ return;
+ }
+ setError("");
+ setLoading(true);
+ try {
+ const { roomId, whepUrl } = await createRoom(roomName.trim());
+ const { peerToken } = await createPeer(roomId, peerName.trim());
+ await joinRoom({ peerToken, peerMetadata: { name: peerName.trim() } });
+ onJoined({ whepUrl, roomName: roomName.trim(), peerName: peerName.trim() });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Something went wrong.");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+