Private Broadcaster
Create 1:1 interactive streams that accept viewer call requests.
What is Direct Streaming?
Direct Streaming enables bidirectional streaming where a broadcaster can accept viewer requests to join the call and stream back to the broadcaster. This creates a 1:1 interactive connection, different from standard broadcasting where viewers only receive the stream.
Key Use Cases:
- 1:1 consultations (doctor-patient, tutor-student)
- Interactive Q&A sessions
- Live customer support with video
- Interview or audition streams
- React
- Vanilla JavaScript
Prerequisites
These docs are for an advanced implementation and assumes you have a basic understanding of @video/video-client-react.
If you are new to @video/video-client-react, we recommend familiarizing yourself with basic concepts first:
Specifically, this guide assumes familiarity with:
- The
usePreviewPlayeranduseAuthClienthooks. - The
useCallControlscustom hook. - The
PreviewPlayercomponent for device controls. - The
Playercomponent for stream playback.
Call Requested Component
A simple UI component for handling incoming call requests from viewers.
Props
The component receives callbacks from the parent:
- setCallRequested: Callback to update call request state
- kickViewer: Callback to kick the viewer from the call
/**
* PROPS INTERFACE
*
* Both props are provided by the PrivateBroadcaster parent component.
*/
interface CallRequestedProps {
/**
* Callback to update the call request state in the parent component
*
* States:
* - "none": No request pending
* - "requested": Viewer has requested to join (this component is shown)
* - "accepted": Broadcaster accepted (viewer's stream will display)
* - "denied": Broadcaster denied (viewer was kicked)
*/
setCallRequested: (callRequested: "none" | "requested" | "accepted" | "denied") => void;
/**
* Callback to kick the viewer from the call
*
* This is the kickViewer function from PrivateBroadcaster
* which calls call.kickViewer() with the viewer's userId.
*/
kickViewer: () => void;
}
Full Component Code
Functionality:
- Accept Button: Sets
callRequestedto "accepted" → Parent displays viewer's stream - Deny Button: Kicks viewer and sets
callRequestedto "denied"
// CallRequested.tsx
/**
* CallRequested Component
*
* OVERVIEW:
* A simple UI component that displays when a viewer requests to join
* a direct streaming session. Provides accept/deny controls for the broadcaster
* to manage incoming call requests.
*
* PURPOSE:
* This component bridges the gap between receiving a streamAdded event
* and making the decision to allow the viewer into the broadcast. It gives
* the broadcaster control over who can join their direct streaming session.
*
* INTEGRATION:
* Used by DirectStreamingBroadcaster when callRequested state is "requested".
* The broadcaster component manages the state and provides the callbacks.
*
* USER FLOW:
* 1. Viewer initiates call request → streamAdded event fires
* 2. DirectStreamingBroadcaster shows this component
* 3. Broadcaster clicks Accept → callRequested set to "accepted" → viewer's stream displays
* 4. OR Broadcaster clicks Deny → viewer kicked → callRequested set to "denied"
*
* CUSTOMIZATION:
* This is an example implementation. In production, you might want to:
* - Show viewer information (name, avatar)
* - Add "block" functionality
* - Include a timeout for auto-denial
* - Show multiple pending requests
* - Add request reason/message
*/
import React, { useCallback } from "react";
/**
* STYLING CONFIGURATION
*
*/
const classNames = {
wrapperClassName: "flex items-center justify-center bg-black/50",
containerClassName: "relative w-96 rounded-lg bg-white p-6 shadow-lg",
titleClassName: "mb-4 text-xl font-bold",
descriptionClassName: "mb-6 text-gray-600",
buttonContainerClassName: "flex justify-end space-x-4",
acceptButtonClassName: "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-full cursor-pointer",
denyButtonClassName: "bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-full cursor-pointer",
};
/**
* PROPS INTERFACE
*
* Both props are provided by the PrivateBroadcaster parent component.
*/
interface CallRequestedProps {
/**
* Callback to update the call request state in the parent component
*
* States:
* - "none": No request pending
* - "requested": Viewer has requested to join (this component is shown)
* - "accepted": Broadcaster accepted (viewer's stream will display)
* - "denied": Broadcaster denied (viewer was kicked)
*/
setCallRequested: (callRequested: "none" | "requested" | "accepted" | "denied") => void;
/**
* Callback to kick the viewer from the call
*
* This is the kickViewer function from PrivateBroadcaster
* which calls call.kickViewer() with the viewer's userId.
*/
kickViewer: () => void;
}
function CallRequested({ setCallRequested, kickViewer }: CallRequestedProps): JSX.Element | null {
/**
* DENY HANDLER
*
* When broadcaster denies the call request:
* 1. Kick the viewer from the call (removes their stream)
* 2. Update state to "denied" (shows denied message in parent)
*
* The kickViewer call triggers:
* - call.kickViewer(userId, false, true)
* - streamRemoved event in parent component
* - Viewer is disconnected from the call
*/
const handleDenyCall = useCallback(() => {
kickViewer();
setCallRequested("denied");
}, [kickViewer, setCallRequested]);
/**
* RENDER: CALL REQUEST MODAL
*
* Simple UI with:
* - Overlay background (semi-transparent black)
* - Centered card with title and description
* - Two action buttons:
* - Accept: Sets state to "accepted" (parent will display viewer's stream)
* - Deny: Kicks viewer and sets state to "denied"
*
* ACCEPT FLOW:
* Clicking Accept sets callRequested to "accepted" which causes the parent
* component to render the Player with the viewer's stream.
*
* DENY FLOW:
* Clicking Deny kicks the viewer (disconnects them) and shows a "denied" message
* in the parent component.
*/
return (
<div className={classNames.wrapperClassName}>
<div className={classNames.containerClassName}>
<h2 className={classNames.titleClassName}>Incoming Call</h2>
<p className={classNames.descriptionClassName}>Someone is requesting to start a call with you.</p>
<div className={classNames.buttonContainerClassName}>
<button
type="button"
onClick={() => setCallRequested("accepted")}
className={classNames.acceptButtonClassName}
>Accept</button>
<button
type="button"
onClick={handleDenyCall}
className={classNames.denyButtonClassName}
>Deny</button>
</div>
</div>
</div>
);
}
export default CallRequested;
Private Broadcaster Component
The Private Broadcaster component demonstrates:
- Bidirectional Streaming - Both Private Broadcaster and Private Viewer see each other
- Call Request Flow - Private Viewer requests → Private Broadcaster accepts/denies → Private Viewer streams back
- Stream Event Handling - React to
streamAdded/streamRemovedevents - Viewer Management - Accept, deny, and kick viewers
The component uses:
- hooks:
useAuthClient,usePreviewPlayerfor authentication and media setup - context:
MediaStreamControllerAPIProvider,PlayerAPIProviderfor sharing instances - PreviewPlayer: Broadcaster's video preview with device controls
- CallRequested: UI for accepting/denying viewer call requests
- useCallControls: Custom hook for managing call and broadcast state
- Player: Displays viewer's incoming stream
State
This component should look very familiar, as it follows the same pattern for setting up a preview player and broadcast that we have demonstrated in Broadcasting a Livestream and other demos.
In this example, we are introducing two new pieces of state:
- viewer: Stores the viewer's peer and stream information when they join
- callRequested: Tracks the call request lifecycle ("none" → "requested" → "accepted"/"denied")
/**
* STATE MANAGEMENT
*
* Two key pieces of state for direct streaming:
* 1. viewer: Stores the viewer's peer and stream information
* 2. callRequested: Tracks the call request lifecycle
*/
const [viewer, setViewer] = useState< types.CallEvents["streamAdded"] | null>(null);
const [callRequested, setCallRequested] = useState<"none" | "requested" | "accepted" | "denied">("none");
Event Listeners
This is the key difference from regular broadcasting. We listen for stream events to know when viewers join and leave:
Event Flow:
- Private Viewer requests to join →
streamAddedevent fires - We set
callRequestedto "requested" → Shows accept/deny UI - Private Broadcaster accepts →
callRequestedset to "accepted" → Show viewer's stream - Private Viewer leaves or is kicked →
streamRemovedevent fires → Clean up
Important: This is a 1:1 connection pattern. For multiple viewers, you'd need to manage an array of viewers instead of a single viewer state.
/**
* STREAM EVENT HANDLING (THE CORE LOGIC)
*
* This is where direct streaming differs from regular broadcasting.
* We listen for streamAdded and streamRemoved events to know when
* viewers join and leave.
*
* EVENT FLOW:
* 1. Viewer requests to join → streamAdded event fires
* 2. We set callRequested to "requested" → Shows accept/deny UI
* 3. Broadcaster accepts → callRequested set to "accepted" → Show viewer's stream
* 4. Viewer leaves or is kicked → streamRemoved event fires → Clean up
*
* IMPORTANT: This is a 1:1 connection pattern. For multiple viewers,
* you'd need to manage an array of viewers instead of a single viewer state.
*/
useEffect(() => {
const onStreamAdded = (ev: types.CallEvents["streamAdded"]): void => {
// A viewer's stream has been added to the call
setCallRequested("requested"); // Show accept/deny UI
setViewer(ev); // Store viewer info for later use
};
const onStreamRemoved = (ev: types.CallEvents["streamRemoved"]): void => {
// Viewer has left or been kicked
setViewer(null); // Clear viewer state
// Note: Player disposal is handled automatically by the Player component
};
if (call != null) {
// Attach event listeners when call is active
call.on("streamAdded", onStreamAdded);
call.on("streamRemoved", onStreamRemoved);
}
// Notify parent of call ID
cbCallId(call?.id ?? "");
// Cleanup: Remove event listeners when call changes or component unmounts
return () => {
call?.off("streamAdded", onStreamAdded);
call?.off("streamRemoved", onStreamRemoved);
};
}, [call, cbCallId]);
Click Handler
Implement the ability to kick viewers from the call:
The kickViewer method:
- Removes the viewer from the call
- Triggers
streamRemovedevent - Parameters:
userId,false(don't ban),true(force kick)
/**
* VIEWER MANAGEMENT: KICK VIEWER
*
* Removes a viewer from the call. This is used both for denying
* initial requests and for kicking viewers during the call.
*
* The kickViewer method:
* - Removes the viewer from the call
* - Triggers streamRemoved event (handled in useEffect above)
* - Parameters:
* - userId: Viewer to kick
* - false: Don't ban (viewer can rejoin)
* - true: Force kick (disconnect immediately)
*/
const handleKickViewer = useCallback((): void => {
if (call == null || viewer?.peer == null || viewer.peer.userId == null) {
throw new Error("Unable to kick viewer: call or viewer not found");
}
call.kickViewer(viewer.peer.userId, false, true).catch((err) => {
throw new Error(`Unable to kick viewer: ${err instanceof Error ? err.message : String(err)}`);
});
}, [viewer, call]);
Render UI
The component uses a split-panel layout with conditional rendering based on call request state:
Layout:
- Left Panel: Broadcaster's encoder (own stream + call controls)
- Right Panel: Conditional based on
callRequestedstate:- "requested": Show accept/deny UI (CallRequested component)
- "accepted": Show viewer's stream + kick button
- "denied": Show denied message
- "none": Empty (no viewer)
/**
* LOADING STATE
*/
if (!mediaStreamController || !previewPlayer) {
return <div className={classNames.loadingScreenClassName} />;
}
/**
* RENDER: SPLIT PANEL LAYOUT
*
* LEFT PANEL: Broadcaster's view
* - Preview Player: Own video preview with device controls
* - Call Controls: Create call, start/stop broadcast
*
* RIGHT PANEL: Viewer interaction (conditional based on callRequested state)
* - "requested": Show accept/deny UI (CallRequested component)
* - "accepted": Show viewer's stream + kick button
* - "denied": Show denied message
* - "none": Empty (no viewer)
*
* STATE FLOW:
* none → requested (streamAdded fires) → accepted/denied (user choice) → none (streamRemoved fires)
*/
return (
<MediaStreamControllerAPIProvider mediaStreamControllerAPI={mediaStreamController}>
<div className={classNames.wrapperClassName}>
{/* Left Panel: Broadcaster's Preview Player */}
<div className={classNames.leftPanelClassName}>
<PlayerAPIProvider playerAPI={previewPlayer}>
<PreviewPlayer classNames={classNames.previewPlayerClassNames}/>
{renderControls()}
</PlayerAPIProvider>
</div>
{/* Right Panel: Viewer Interaction */}
<div className={classNames.rightPanelClassName}>
<div className={classNames.callRequestedClassName}>
{/* State: Call Requested - Show Accept/Deny UI */}
{callRequested === "requested" &&
<CallRequested
setCallRequested={setCallRequested}
kickViewer={handleKickViewer}
/>
}
{/* State: Accepted - Show Viewer's Stream */}
{callRequested === "accepted" && viewer?.stream != null &&
<>
<Player source={viewer.stream} />
<button onClick={handleKickViewer} className={classNames.kickViewerButtonClassName}>
Kick Viewer
</button>
</>
}
{/* State: Denied - Show Denied Message */}
{callRequested === "denied" && <div>Call denied</div>}
</div>
</div>
</div>
</MediaStreamControllerAPIProvider>
);
Full Component Code
// PrivateBroadcaster.tsx
/**
* PrivateBroadcaster Component
*
* ADVANCED IMPLEMENTATION: Private Calls (Bidirectional)
*
* This component enables bidirectional streaming where a private broadcaster can accept
* private viewer requests to join the call and stream back to the private broadcaster. This creates
* a 1:1 interactive connection, different from standard broadcasting where viewers
* only receive the stream.
*
* KEY CONCEPTS:
* 1. BIDIRECTIONAL STREAMING: Both private broadcaster and private viewer see each other
* 2. CALL REQUEST FLOW: Private viewer requests → Private broadcaster accepts/denies → Private viewer streams back
* 3. STREAM EVENT HANDLING: React to streamAdded/streamRemoved events
* 4. VIEWER MANAGEMENT: Accept, deny, and kick viewers
*
* USE CASES:
* - 1:1 consultations (doctor-patient, tutor-student)
* - Interactive Q&A sessions
* - Live customer support with video
* - Interview or audition streams
* - Any scenario requiring broadcaster-viewer interaction
*
* ARCHITECTURE:
* - Left panel: Broadcaster's preview player (own stream + call controls)
* - Right panel: Viewer's player (when accepted) + call request UI
*
* PREREQUISITES:
* Assumes familiarity with:
* - Preview Player setup and broadcast management
* - Player components and manifest playback
* - Call event handling
* - Authentication and call creation
*/
import React, { useState, useEffect, useCallback } from "react";
import { types, hooks, context } from "@video/video-client-react";
/**
* REUSABLE COMPONENTS
*
* - Preview Player: Broadcaster's video preview with device controls
* - CallRequested: UI for accepting/denying viewer call requests
* - useCallControls: Hook for managing call and broadcast state
* - Player: Displays viewer's incoming stream
*/
import PreviewPlayer from "../../components/PreviewPlayer";
import CallRequested from "./CallRequested";
import { useCallControls } from "../../components/CallControls";
import Player from "../../components/Player";
const { useAuthClient, usePreviewPlayer } = hooks;
const { MediaStreamControllerAPIProvider, PlayerAPIProvider } = context;
/**
* STYLING CONFIGURATION
*
*/
const classNames = {
wrapperClassName: "flex flex-row",
leftPanelClassName: "w-1/2",
rightPanelClassName: "w-1/2",
playerWrapperClassName: "w-full bg-gray-200",
callRequestedClassName: "w-full bg-gray-200",
viewerPlayerClassName: "w-full bg-gray-200",
kickViewerButtonClassName: "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-full cursor-pointer",
loadingScreenClassName: "w-full h-full bg-gray-200",
previewPlayerClassNames: {
wrapperClassName: "gap-4 flex flex-col",
videoContainerClassName: "w-full h-auto",
videoClassName: "w-full h-auto",
controlBarClassName: "flex flex-col",
controlBarItemClassName: "flex flex-col my-2 gap-2",
},
};
/**
* PROPS INTERFACE
*
* Standard broadcast props plus callbacks for parent component
*/
interface DirectStreamingBroadcasterProps {
backendEndpoint: string;
streamKey: string;
token: string;
cbCallId: (callId: string) => void;
cbBroadcast: (broadcast: types.BroadcastAPI | null) => void;
}
function DirectStreamingBroadcaster({ backendEndpoint, streamKey, token, cbCallId, cbBroadcast }: DirectStreamingBroadcasterProps): React.ReactElement | null {
/**
* STATE MANAGEMENT
*
* Two key pieces of state for direct streaming:
* 1. viewer: Stores the viewer's peer and stream information
* 2. callRequested: Tracks the call request lifecycle
*/
const [viewer, setViewer] = useState< types.CallEvents["streamAdded"] | null>(null);
const [callRequested, setCallRequested] = useState<"none" | "requested" | "accepted" | "denied">("none");
/**
* INITIALIZE BROADCASTER'S MEDIA
*
* Standard preview player setup - same as regular broadcasting
*/
const { mediaStreamController, previewPlayer } = usePreviewPlayer({});
/**
* AUTHENTICATION
*
* Create auth client with broadcaster scope
*/
const authClient = useAuthClient(token);
/**
* CALL AND BROADCAST SETUP
*
* Use the CallControls hook to manage call and broadcast state.
* This is the same pattern as standard broadcasting.
*/
const callOptions = {streamKey, user: {userId: "123", displayName: "John Doe"}, backendEndpoints: [backendEndpoint], auth: authClient};
const broadcastOptions = {streamName: "default"};
const { renderControls, call, broadcast, setCall, setBroadcast } = useCallControls({callOptions, broadcastOptions, type: "owner"});
/**
* (OPTIONAL) NOTIFY PARENT OF BROADCAST STATE
*/
useEffect(() => {
cbBroadcast(broadcast);
}, [broadcast, cbBroadcast]);
/**
* STREAM EVENT HANDLING (THE CORE LOGIC)
*
* This is where direct streaming differs from regular broadcasting.
* We listen for streamAdded and streamRemoved events to know when
* viewers join and leave.
*
* EVENT FLOW:
* 1. Viewer requests to join → streamAdded event fires
* 2. We set callRequested to "requested" → Shows accept/deny UI
* 3. Broadcaster accepts → callRequested set to "accepted" → Show viewer's stream
* 4. Viewer leaves or is kicked → streamRemoved event fires → Clean up
*
* IMPORTANT: This is a 1:1 connection pattern. For multiple viewers,
* you'd need to manage an array of viewers instead of a single viewer state.
*/
useEffect(() => {
const onStreamAdded = (ev: types.CallEvents["streamAdded"]): void => {
// A viewer's stream has been added to the call
setCallRequested("requested"); // Show accept/deny UI
setViewer(ev); // Store viewer info for later use
};
const onStreamRemoved = (ev: types.CallEvents["streamRemoved"]): void => {
// Viewer has left or been kicked
setViewer(null); // Clear viewer state
// Note: Player disposal is handled automatically by the Player component
};
if (call != null) {
// Attach event listeners when call is active
call.on("streamAdded", onStreamAdded);
call.on("streamRemoved", onStreamRemoved);
}
// Notify parent of call ID
cbCallId(call?.id ?? "");
// Cleanup: Remove event listeners when call changes or component unmounts
return () => {
call?.off("streamAdded", onStreamAdded);
call?.off("streamRemoved", onStreamRemoved);
};
}, [call, cbCallId]);
/**
* VIEWER MANAGEMENT: KICK VIEWER
*
* Removes a viewer from the call. This is used both for denying
* initial requests and for kicking viewers during the call.
*
* The kickViewer method:
* - Removes the viewer from the call
* - Triggers streamRemoved event (handled in useEffect above)
* - Parameters:
* - userId: Viewer to kick
* - false: Don't ban (viewer can rejoin)
* - true: Force kick (disconnect immediately)
*/
const handleKickViewer = useCallback((): void => {
if (call == null || viewer?.peer == null || viewer.peer.userId == null) {
throw new Error("Unable to kick viewer: call or viewer not found");
}
call.kickViewer(viewer.peer.userId, false, true).catch((err) => {
throw new Error(`Unable to kick viewer: ${err instanceof Error ? err.message : String(err)}`);
});
}, [viewer, call]);
/**
* CLEANUP EFFECTS
*
* Proper cleanup is critical for direct streaming to avoid:
* - Memory leaks from undisposed players
* - Lingering WebRTC connections
* - Event listener leaks
*
* We dispose in separate effects to handle each resource independently.
*/
useEffect(() => {
return () => {
if (call == null) return;
call.dispose("Disposed by useEffect - call cleanup");
setCall(null);
setBroadcast(null);
};
}, [call, setCall, setBroadcast]);
useEffect(() => {
return () => {
if (mediaStreamController == null) return;
mediaStreamController.dispose("Disposed by useEffect - mediaStreamController cleanup");
};
}, [mediaStreamController]);
useEffect(() => {
return () => {
if (previewPlayer == null) return;
previewPlayer.dispose("Disposed by useEffect - previewPlayer cleanup");
};
}, [previewPlayer]);
/**
* LOADING STATE
*/
if (!mediaStreamController || !previewPlayer) {
return <div className={classNames.loadingScreenClassName} />;
}
/**
* RENDER: SPLIT PANEL LAYOUT
*
* LEFT PANEL: Broadcaster's view
* - Preview Player: Own video preview with device controls
* - Call Controls: Create call, start/stop broadcast
*
* RIGHT PANEL: Viewer interaction (conditional based on callRequested state)
* - "requested": Show accept/deny UI (CallRequested component)
* - "accepted": Show viewer's stream + kick button
* - "denied": Show denied message
* - "none": Empty (no viewer)
*
* STATE FLOW:
* none → requested (streamAdded fires) → accepted/denied (user choice) → none (streamRemoved fires)
*/
return (
<MediaStreamControllerAPIProvider mediaStreamControllerAPI={mediaStreamController}>
<div className={classNames.wrapperClassName}>
{/* Left Panel: Broadcaster's Preview Player */}
<div className={classNames.leftPanelClassName}>
<PlayerAPIProvider playerAPI={previewPlayer}>
<PreviewPlayer classNames={classNames.previewPlayerClassNames}/>
{renderControls()}
</PlayerAPIProvider>
</div>
{/* Right Panel: Viewer Interaction */}
<div className={classNames.rightPanelClassName}>
<div className={classNames.callRequestedClassName}>
{/* State: Call Requested - Show Accept/Deny UI */}
{callRequested === "requested" &&
<CallRequested
setCallRequested={setCallRequested}
kickViewer={handleKickViewer}
/>
}
{/* State: Accepted - Show Viewer's Stream */}
{callRequested === "accepted" && viewer?.stream != null &&
<>
<Player source={viewer.stream} />
<button onClick={handleKickViewer} className={classNames.kickViewerButtonClassName}>
Kick Viewer
</button>
</>
}
{/* State: Denied - Show Denied Message */}
{callRequested === "denied" && <div>Call denied</div>}
</div>
</div>
</div>
</MediaStreamControllerAPIProvider>
);
}
export default DirectStreamingBroadcaster;
Full Code
Private Broadcaster Components
// CallRequested.tsx
/**
* CallRequested Component
*
* OVERVIEW:
* A simple UI component that displays when a viewer requests to join
* a direct streaming session. Provides accept/deny controls for the broadcaster
* to manage incoming call requests.
*
* PURPOSE:
* This component bridges the gap between receiving a streamAdded event
* and making the decision to allow the viewer into the broadcast. It gives
* the broadcaster control over who can join their direct streaming session.
*
* INTEGRATION:
* Used by DirectStreamingBroadcaster when callRequested state is "requested".
* The broadcaster component manages the state and provides the callbacks.
*
* USER FLOW:
* 1. Viewer initiates call request → streamAdded event fires
* 2. DirectStreamingBroadcaster shows this component
* 3. Broadcaster clicks Accept → callRequested set to "accepted" → viewer's stream displays
* 4. OR Broadcaster clicks Deny → viewer kicked → callRequested set to "denied"
*
* CUSTOMIZATION:
* This is an example implementation. In production, you might want to:
* - Show viewer information (name, avatar)
* - Add "block" functionality
* - Include a timeout for auto-denial
* - Show multiple pending requests
* - Add request reason/message
*/
import React, { useCallback } from "react";
/**
* STYLING CONFIGURATION
*
*/
const classNames = {
wrapperClassName: "flex items-center justify-center bg-black/50",
containerClassName: "relative w-96 rounded-lg bg-white p-6 shadow-lg",
titleClassName: "mb-4 text-xl font-bold",
descriptionClassName: "mb-6 text-gray-600",
buttonContainerClassName: "flex justify-end space-x-4",
acceptButtonClassName: "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-full cursor-pointer",
denyButtonClassName: "bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-full cursor-pointer",
};
/**
* PROPS INTERFACE
*
* Both props are provided by the PrivateBroadcaster parent component.
*/
interface CallRequestedProps {
/**
* Callback to update the call request state in the parent component
*
* States:
* - "none": No request pending
* - "requested": Viewer has requested to join (this component is shown)
* - "accepted": Broadcaster accepted (viewer's stream will display)
* - "denied": Broadcaster denied (viewer was kicked)
*/
setCallRequested: (callRequested: "none" | "requested" | "accepted" | "denied") => void;
/**
* Callback to kick the viewer from the call
*
* This is the kickViewer function from PrivateBroadcaster
* which calls call.kickViewer() with the viewer's userId.
*/
kickViewer: () => void;
}
function CallRequested({ setCallRequested, kickViewer }: CallRequestedProps): JSX.Element | null {
/**
* DENY HANDLER
*
* When broadcaster denies the call request:
* 1. Kick the viewer from the call (removes their stream)
* 2. Update state to "denied" (shows denied message in parent)
*
* The kickViewer call triggers:
* - call.kickViewer(userId, false, true)
* - streamRemoved event in parent component
* - Viewer is disconnected from the call
*/
const handleDenyCall = useCallback(() => {
kickViewer();
setCallRequested("denied");
}, [kickViewer, setCallRequested]);
/**
* RENDER: CALL REQUEST MODAL
*
* Simple UI with:
* - Overlay background (semi-transparent black)
* - Centered card with title and description
* - Two action buttons:
* - Accept: Sets state to "accepted" (parent will display viewer's stream)
* - Deny: Kicks viewer and sets state to "denied"
*
* ACCEPT FLOW:
* Clicking Accept sets callRequested to "accepted" which causes the parent
* component to render the Player with the viewer's stream.
*
* DENY FLOW:
* Clicking Deny kicks the viewer (disconnects them) and shows a "denied" message
* in the parent component.
*/
return (
<div className={classNames.wrapperClassName}>
<div className={classNames.containerClassName}>
<h2 className={classNames.titleClassName}>Incoming Call</h2>
<p className={classNames.descriptionClassName}>Someone is requesting to start a call with you.</p>
<div className={classNames.buttonContainerClassName}>
<button
type="button"
onClick={() => setCallRequested("accepted")}
className={classNames.acceptButtonClassName}
>Accept</button>
<button
type="button"
onClick={handleDenyCall}
className={classNames.denyButtonClassName}
>Deny</button>
</div>
</div>
</div>
);
}
export default CallRequested;
// PrivateBroadcaster.tsx
/**
* PrivateBroadcaster Component
*
* ADVANCED IMPLEMENTATION: Private Calls (Bidirectional)
*
* This component enables bidirectional streaming where a private broadcaster can accept
* private viewer requests to join the call and stream back to the private broadcaster. This creates
* a 1:1 interactive connection, different from standard broadcasting where viewers
* only receive the stream.
*
* KEY CONCEPTS:
* 1. BIDIRECTIONAL STREAMING: Both private broadcaster and private viewer see each other
* 2. CALL REQUEST FLOW: Private viewer requests → Private broadcaster accepts/denies → Private viewer streams back
* 3. STREAM EVENT HANDLING: React to streamAdded/streamRemoved events
* 4. VIEWER MANAGEMENT: Accept, deny, and kick viewers
*
* USE CASES:
* - 1:1 consultations (doctor-patient, tutor-student)
* - Interactive Q&A sessions
* - Live customer support with video
* - Interview or audition streams
* - Any scenario requiring broadcaster-viewer interaction
*
* ARCHITECTURE:
* - Left panel: Broadcaster's preview player (own stream + call controls)
* - Right panel: Viewer's player (when accepted) + call request UI
*
* PREREQUISITES:
* Assumes familiarity with:
* - Preview Player setup and broadcast management
* - Player components and manifest playback
* - Call event handling
* - Authentication and call creation
*/
import React, { useState, useEffect, useCallback } from "react";
import { types, hooks, context } from "@video/video-client-react";
/**
* REUSABLE COMPONENTS
*
* - Preview Player: Broadcaster's video preview with device controls
* - CallRequested: UI for accepting/denying viewer call requests
* - useCallControls: Hook for managing call and broadcast state
* - Player: Displays viewer's incoming stream
*/
import PreviewPlayer from "../../components/PreviewPlayer";
import CallRequested from "./CallRequested";
import { useCallControls } from "../../components/CallControls";
import Player from "../../components/Player";
const { useAuthClient, usePreviewPlayer } = hooks;
const { MediaStreamControllerAPIProvider, PlayerAPIProvider } = context;
/**
* STYLING CONFIGURATION
*
*/
const classNames = {
wrapperClassName: "flex flex-row",
leftPanelClassName: "w-1/2",
rightPanelClassName: "w-1/2",
playerWrapperClassName: "w-full bg-gray-200",
callRequestedClassName: "w-full bg-gray-200",
viewerPlayerClassName: "w-full bg-gray-200",
kickViewerButtonClassName: "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-full cursor-pointer",
loadingScreenClassName: "w-full h-full bg-gray-200",
previewPlayerClassNames: {
wrapperClassName: "gap-4 flex flex-col",
videoContainerClassName: "w-full h-auto",
videoClassName: "w-full h-auto",
controlBarClassName: "flex flex-col",
controlBarItemClassName: "flex flex-col my-2 gap-2",
},
};
/**
* PROPS INTERFACE
*
* Standard broadcast props plus callbacks for parent component
*/
interface DirectStreamingBroadcasterProps {
backendEndpoint: string;
streamKey: string;
token: string;
cbCallId: (callId: string) => void;
cbBroadcast: (broadcast: types.BroadcastAPI | null) => void;
}
function DirectStreamingBroadcaster({ backendEndpoint, streamKey, token, cbCallId, cbBroadcast }: DirectStreamingBroadcasterProps): React.ReactElement | null {
/**
* STATE MANAGEMENT
*
* Two key pieces of state for direct streaming:
* 1. viewer: Stores the viewer's peer and stream information
* 2. callRequested: Tracks the call request lifecycle
*/
const [viewer, setViewer] = useState< types.CallEvents["streamAdded"] | null>(null);
const [callRequested, setCallRequested] = useState<"none" | "requested" | "accepted" | "denied">("none");
/**
* INITIALIZE BROADCASTER'S MEDIA
*
* Standard preview player setup - same as regular broadcasting
*/
const { mediaStreamController, previewPlayer } = usePreviewPlayer({});
/**
* AUTHENTICATION
*
* Create auth client with broadcaster scope
*/
const authClient = useAuthClient(token);
/**
* CALL AND BROADCAST SETUP
*
* Use the CallControls hook to manage call and broadcast state.
* This is the same pattern as standard broadcasting.
*/
const callOptions = {streamKey, user: {userId: "123", displayName: "John Doe"}, backendEndpoints: [backendEndpoint], auth: authClient};
const broadcastOptions = {streamName: "default"};
const { renderControls, call, broadcast, setCall, setBroadcast } = useCallControls({callOptions, broadcastOptions, type: "owner"});
/**
* (OPTIONAL) NOTIFY PARENT OF BROADCAST STATE
*/
useEffect(() => {
cbBroadcast(broadcast);
}, [broadcast, cbBroadcast]);
/**
* STREAM EVENT HANDLING (THE CORE LOGIC)
*
* This is where direct streaming differs from regular broadcasting.
* We listen for streamAdded and streamRemoved events to know when
* viewers join and leave.
*
* EVENT FLOW:
* 1. Viewer requests to join → streamAdded event fires
* 2. We set callRequested to "requested" → Shows accept/deny UI
* 3. Broadcaster accepts → callRequested set to "accepted" → Show viewer's stream
* 4. Viewer leaves or is kicked → streamRemoved event fires → Clean up
*
* IMPORTANT: This is a 1:1 connection pattern. For multiple viewers,
* you'd need to manage an array of viewers instead of a single viewer state.
*/
useEffect(() => {
const onStreamAdded = (ev: types.CallEvents["streamAdded"]): void => {
// A viewer's stream has been added to the call
setCallRequested("requested"); // Show accept/deny UI
setViewer(ev); // Store viewer info for later use
};
const onStreamRemoved = (ev: types.CallEvents["streamRemoved"]): void => {
// Viewer has left or been kicked
setViewer(null); // Clear viewer state
// Note: Player disposal is handled automatically by the Player component
};
if (call != null) {
// Attach event listeners when call is active
call.on("streamAdded", onStreamAdded);
call.on("streamRemoved", onStreamRemoved);
}
// Notify parent of call ID
cbCallId(call?.id ?? "");
// Cleanup: Remove event listeners when call changes or component unmounts
return () => {
call?.off("streamAdded", onStreamAdded);
call?.off("streamRemoved", onStreamRemoved);
};
}, [call, cbCallId]);
/**
* VIEWER MANAGEMENT: KICK VIEWER
*
* Removes a viewer from the call. This is used both for denying
* initial requests and for kicking viewers during the call.
*
* The kickViewer method:
* - Removes the viewer from the call
* - Triggers streamRemoved event (handled in useEffect above)
* - Parameters:
* - userId: Viewer to kick
* - false: Don't ban (viewer can rejoin)
* - true: Force kick (disconnect immediately)
*/
const handleKickViewer = useCallback((): void => {
if (call == null || viewer?.peer == null || viewer.peer.userId == null) {
throw new Error("Unable to kick viewer: call or viewer not found");
}
call.kickViewer(viewer.peer.userId, false, true).catch((err) => {
throw new Error(`Unable to kick viewer: ${err instanceof Error ? err.message : String(err)}`);
});
}, [viewer, call]);
/**
* CLEANUP EFFECTS
*
* Proper cleanup is critical for direct streaming to avoid:
* - Memory leaks from undisposed players
* - Lingering WebRTC connections
* - Event listener leaks
*
* We dispose in separate effects to handle each resource independently.
*/
useEffect(() => {
return () => {
if (call == null) return;
call.dispose("Disposed by useEffect - call cleanup");
setCall(null);
setBroadcast(null);
};
}, [call, setCall, setBroadcast]);
useEffect(() => {
return () => {
if (mediaStreamController == null) return;
mediaStreamController.dispose("Disposed by useEffect - mediaStreamController cleanup");
};
}, [mediaStreamController]);
useEffect(() => {
return () => {
if (previewPlayer == null) return;
previewPlayer.dispose("Disposed by useEffect - previewPlayer cleanup");
};
}, [previewPlayer]);
/**
* LOADING STATE
*/
if (!mediaStreamController || !previewPlayer) {
return <div className={classNames.loadingScreenClassName} />;
}
/**
* RENDER: SPLIT PANEL LAYOUT
*
* LEFT PANEL: Broadcaster's view
* - Preview Player: Own video preview with device controls
* - Call Controls: Create call, start/stop broadcast
*
* RIGHT PANEL: Viewer interaction (conditional based on callRequested state)
* - "requested": Show accept/deny UI (CallRequested component)
* - "accepted": Show viewer's stream + kick button
* - "denied": Show denied message
* - "none": Empty (no viewer)
*
* STATE FLOW:
* none → requested (streamAdded fires) → accepted/denied (user choice) → none (streamRemoved fires)
*/
return (
<MediaStreamControllerAPIProvider mediaStreamControllerAPI={mediaStreamController}>
<div className={classNames.wrapperClassName}>
{/* Left Panel: Broadcaster's Preview Player */}
<div className={classNames.leftPanelClassName}>
<PlayerAPIProvider playerAPI={previewPlayer}>
<PreviewPlayer classNames={classNames.previewPlayerClassNames}/>
{renderControls()}
</PlayerAPIProvider>
</div>
{/* Right Panel: Viewer Interaction */}
<div className={classNames.rightPanelClassName}>
<div className={classNames.callRequestedClassName}>
{/* State: Call Requested - Show Accept/Deny UI */}
{callRequested === "requested" &&
<CallRequested
setCallRequested={setCallRequested}
kickViewer={handleKickViewer}
/>
}
{/* State: Accepted - Show Viewer's Stream */}
{callRequested === "accepted" && viewer?.stream != null &&
<>
<Player source={viewer.stream} />
<button onClick={handleKickViewer} className={classNames.kickViewerButtonClassName}>
Kick Viewer
</button>
</>
}
{/* State: Denied - Show Denied Message */}
{callRequested === "denied" && <div>Call denied</div>}
</div>
</div>
</div>
</MediaStreamControllerAPIProvider>
);
}
export default DirectStreamingBroadcaster;
Supporting Components
The following components are used by the Private Broadcaster Component and are documented in Set Up A Livestream Video:
// PreviewPlayer.tsx
/**
* Preview Player Component
*
* OVERVIEW:
* A reusable preview player component that displays a video preview and
* provides standard device controls. This component is designed to be
* dropped into any video broadcasting application with minimal configuration.
*
* WHAT IT DOES:
* - Displays video preview (what your camera sees)
* - Provides toggle buttons for camera and microphone
* - Offers device selection dropdowns (which camera, which mic)
* - Allows resolution/quality selection
* - Automatically connects to video client context
*
* KEY FEATURES:
* - Fully self-contained: No state management needed in parent
* - Customizable styling via className props
* - Responsive layout (adapts to screen size)
* - Accessibility-friendly controls
* - Built-in error handling
*
* REQUIREMENTS:
* Must be used inside:
* - MediaStreamControllerAPIProvider (for device control)
* - PlayerAPIProvider (for video display)
*
* @example
* ```tsx
* <MediaStreamControllerAPIProvider mediaStreamControllerAPI={msc}>
* <PlayerAPIProvider playerAPI={player}>
* <Encoder />
* </PlayerAPIProvider>
* </MediaStreamControllerAPIProvider>
* ```
*
* @example With custom styling:
* ```tsx
* <Encoder
* classNames={{
* wrapperClassName: "my-custom-layout",
* videoClassName: "rounded-lg shadow-lg"
* }}
* />
* ```
*/
import React, { memo, useRef } from "react";
import {components, } from "@video/video-client-react";
/**
* EXTRACT UI COMPONENTS
*
* These are pre-built components from @video/video-client-react:
*
* - Video: Displays the video stream
* - AudioSourceSelect: Dropdown to select microphone
* - VideoSourceSelect: Dropdown to select camera
* - ResolutionSelect: Dropdown to select video quality
* - ToggleMicButton: Button to mute/unmute microphone
* - ToggleCameraButton: Button to enable/disable camera
*
* All these components automatically connect to the video client
* context, so they "just work" without prop drilling.
*/
const { Video, AudioSourceSelect, VideoSourceSelect, ResolutionSelect, ToggleMicButton, ToggleCameraButton } = components;
/**
* STYLING INTERFACE
*
* */
interface PreviewPlayerClassNames {
wrapperClassName: string;
videoContainerClassName: string;
videoClassName: string;
controlBarClassName: string;
cntrolBarItemClassName: string;
}
/**
* DEFAULT STYLING
*
* Default styling using Tailwind CSS classes.
*/
const defaultClassNames = {
wrapperClassName: "gap-4 flex flex-row",
videoContainerClassName: "md:w-1/2",
videoClassName: "w-full h-auto",
controlBarClassName: "flex flex-col",
controlBarItemClassName: "flex flex-col my-2 gap-2",
};
/**
* PROPS INTERFACE
*
*/
interface PreviewPlayerProps {
/** Optional custom class names (partial override of defaults) */
classNames?: Partial<PreviewPlayerClassNames>;
}
function PreviewPlayer({classNames}: PreviewPlayerProps): JSX.Element {
const mergedClassNames = { ...defaultClassNames,...classNames };
/**
* VIDEO ELEMENT REF
*
* This is a required prop for the Video component.
*/
const videoElement = useRef<HTMLVideoElement>(null);
/**
* RENDER THE PREVIEW PLAYER UI
*
*/
return (
<div className={mergedClassNames.wrapperClassName}>
{/* VIDEO PREVIEW SECTION */}
<div className={mergedClassNames.videoContainerClassName}>
{/*
The Video component displays what your camera sees.
It automatically connects to the PlayerAPI from context.
*/}
<Video ref={videoElement} className={mergedClassNames.videoClassName} />
</div>
{/* CONTROLS SECTION */}
<div className={mergedClassNames.controlBarClassName}>
{/*
Toggle Controls Group
Quick on/off buttons for camera and microphone
*/}
<div className={mergedClassNames.controlBarItemClassName}>
<ToggleMicButton />
<ToggleCameraButton />
</div>
{/*
Device Selection Group
Dropdowns for choosing which camera/mic and quality
*/}
<div className={mergedClassNames.controlBarItemClassName}>
<AudioSourceSelect />
<VideoSourceSelect />
<ResolutionSelect />
</div>
</div>
</div>
);
}
/**
* EXPORT WITH MEMOIZATION
*
*/
export default memo(PreviewPlayer);
// CallControls.tsx
/**
* CallControls Component & Hook
*
* OVERVIEW:
* This file provides both a component and a custom hook for managing
* call and broadcast state with automatic UI controls. It's a powerful
* abstraction that handles all the complexity of call/broadcast lifecycle.
*
* WHAT IT DOES FOR YOU:
* - Manages call and broadcast state automatically
* - Renders the right buttons based on current state
* - Handles state transitions (no call → call → broadcasting)
* - Provides callbacks for cleanup and notifications
* - Supports multiple usage patterns (owner, participant, broadcast-only)
*
* KEY BENEFITS:
* - No need to manually track call/broadcast state
* - No need to conditionally render buttons yourself
* - No need to write call creation/disposal logic
* - Consistent UX across your application
* - Easy to extend and customize
*
* USAGE PATTERNS:
* 1. "owner": Creates and hosts calls (shows CreateCallButton)
* 2. "participant": Joins existing calls (shows JoinCallButton)
* 3. "broadcast-controls-only": Only manages broadcast (requires existing call)
*
* @example As a hook:
* ```tsx
* const { renderControls, call, broadcast } = useCallControls({
* callOptions: { streamKey, auth, ... },
* broadcastOptions: { streamName: 'default' },
* type: 'owner'
* });
*
* return <div>{renderControls()}</div>;
* ```
*
* @example As a component:
* ```tsx
* <CallControls
* callOptions={{ streamKey, auth, ... }}
* broadcastOptions={{ streamName: 'default' }}
* type="owner"
* />
* ```
*/
import React, { useState } from "react";
import { components, types, context } from "@video/video-client-react";
/**
* EXTRACT PRE-BUILT BUTTON COMPONENTS
*
* These components handle button UI and click logic:
* - CreateCallButton: Creates a new call
* - JoinCallButton: Joins an existing call by ID
* - EndCallButton: Ends the current call
* - StartBroadcastButton: Starts broadcasting to a call
* - EndBroadcastButton: Stops broadcasting
*
* All buttons automatically handle loading states, errors, and cleanup.
*/
const { CreateCallButton, EndCallButton, StartBroadcastButton, EndBroadcastButton, JoinCallButton } = components;
/**
* EXTRACT CONTEXT PROVIDERS
*
* These make call and broadcast instances available to button components:
* - CallAPIProvider: Shares call instance with children
* - BroadcastAPIProvider: Shares broadcast instance with children
*/
const { CallAPIProvider, BroadcastAPIProvider } = context;
interface CallControlsOptions {
callOptions?: types.CallOptions;
broadcastOptions: types.BroadcastOptions;
type: 'owner' | 'participant' | 'broadcast-controls-only';
call?: types.CallAPI | null;
}
type CallState = 'no-call-owner' | 'no-call-participant' | 'call-no-broadcast' | 'call-with-broadcast' | 'start-broadcast-only' | 'end-broadcast-only';
interface CallControlsReturn {
call: types.CallAPI | null;
broadcast: types.BroadcastAPI | null;
setCall: (call: types.CallAPI | null) => void;
setBroadcast: (broadcast: types.BroadcastAPI | null) => void;
state: CallState;
renderControls: () => React.ReactElement;
}
/**
* Utility function to determine the current call/broadcast state
*/
function getCallState(call: types.CallAPI | null, broadcast: types.BroadcastAPI | null, type: 'owner' | 'participant' | 'broadcast-controls-only'): CallState {
switch (type) {
case 'owner':
if (call == null) {
return 'no-call-owner';
}
if (broadcast == null) {
return 'call-no-broadcast';
}
return 'call-with-broadcast';
case 'participant':
if (call == null) {
return 'no-call-participant';
}
if (broadcast == null) {
return 'call-no-broadcast';
}
return 'call-with-broadcast';
case 'broadcast-controls-only':
if (call == null) {
throw new Error('Call is required when type is broadcast-controls-only');
}
if (broadcast == null) {
return 'start-broadcast-only';
}
return 'end-broadcast-only';
default:
// This should never happen if TypeScript types are correct
const _exhaustive: never = type;
throw new Error(`Unknown type: ${_exhaustive}`);
}
}
/**
* Custom hook for managing broadcaster call and broadcast state with controls
*
* @param options - Call and broadcast configuration options
* @returns Object containing state, setters, and a render function for controls
*
* @example
* ```tsx
* function MyComponent() {
* const { renderControls } = useBroadcasterCallControls({
* callOptions: { streamKey: '...', auth: authClient, ... },
* broadcastOptions: { streamName: 'default' }
* });
*
* return <div>{renderControls()}</div>;
* }
* ```
*/
function useCallControls(options: CallControlsOptions): CallControlsReturn {
const { callOptions, broadcastOptions } = options;
const [call, setCall] = useState<types.CallAPI | null>(options.call ?? null);
const [broadcast, setBroadcast] = useState<types.BroadcastAPI | null>(null);
const state = getCallState(call, broadcast, options.type);
/**
* Renders the appropriate controls based on current call/broadcast state
*/
const renderControls = (): React.ReactElement => {
switch (state) {
case 'no-call-owner':
if (callOptions == null) {
throw new Error('Call options are required');
}
// No active call - show CreateCallButton
return (
<CreateCallButton
callOptions={callOptions}
setCall={setCall}
/>
);
case 'no-call-participant':
if (callOptions == null || callOptions.callId == null) {
throw new Error('Call options and call ID are required');
}
// No active call - show JoinCallButton
return <JoinCallButton callId={callOptions.callId} joinCallOptions={callOptions} setCall={setCall} />;
case 'call-no-broadcast':
// Call active, no broadcast - show EndCallButton and StartBroadcastButton
return (
<CallAPIProvider callAPI={call!}>
<EndCallButton onDisposed={() => setCall(null)} />
<StartBroadcastButton
broadcastOptions={broadcastOptions}
setBroadcast={setBroadcast}
/>
</CallAPIProvider>
);
case 'call-with-broadcast':
// Call and broadcast active - show EndCallButton and EndBroadcastButton
return (
<CallAPIProvider callAPI={call!}>
<EndCallButton onDisposed={() => setCall(null)} />
<BroadcastAPIProvider broadcastAPI={broadcast!}>
<EndBroadcastButton onDisposed={() => setBroadcast(null)} />
</BroadcastAPIProvider>
</CallAPIProvider>
);
case 'start-broadcast-only':
return (
<CallAPIProvider callAPI={call!}>
<StartBroadcastButton broadcastOptions={broadcastOptions} setBroadcast={setBroadcast} />
</CallAPIProvider>
);
case 'end-broadcast-only':
return (
<CallAPIProvider callAPI={call!}>
<BroadcastAPIProvider broadcastAPI={broadcast!}>
<EndBroadcastButton onDisposed={() => setBroadcast(null)} />
</BroadcastAPIProvider>
</CallAPIProvider>
);
default:
// Exhaustive check - TypeScript will error if we miss a case
const _exhaustive: CallState = state;
return _exhaustive;
}
};
return {
call,
broadcast,
setCall,
setBroadcast,
state,
renderControls,
};
}
/**
* Component wrapper for the broadcaster call controls hook
* Manages call and broadcast state internally
*/
function CallControls(options: CallControlsOptions): React.ReactElement {
const { renderControls } = useCallControls(options);
return renderControls();
}
export default CallControls;
export { useCallControls, getCallState };
export type { CallControlsOptions, CallControlsReturn, CallState };
This guide demonstrates an ADVANCED implementation of bidirectional streaming using vanilla JavaScript with the @video/video-client-core library, from the broadcaster's perspective.
Prerequisites
This is an advanced implementation that assumes familiarity with:
- Basic preview player setup: See Broadcasting a Livestream
- Call and broadcast concepts: See Broadcasting a Livestream
- Stream event handling patterns: Understanding of WebRTC events
Before you begin, you'll need:
- Authentication Token: A JWT token with "broadcaster" scope (from Native Frame)
- Stream Key: A unique identifier for your stream (from Native Frame)
- Backend Endpoint: Your Native Frame backend URL
Overview
The private broadcaster application consists of three main files:
- private-broadcaster.html - HTML structure, styles, and script imports
- auth.js - Authentication configuration and client setup
- preview-player-utils.js - Utility functions for setting up the broadcaster's encoder
- private-broadcaster.js - Main application logic for bidirectional streaming
Creating a Direct Streaming Broadcaster with Vanilla JavaScript
HTML
The page is organized into two main sections:
Broadcaster Section (Top):
encoderContainer: Will be populated with broadcaster's video preview and device controlscallBtn: Creates/ends the call connectionbroadcastBtn: Starts/stops the broadcaster's broadcast
Viewer Section (Bottom):
viewerVideo: Displays the viewer's incoming stream (when viewer joins)kickViewerBtn: Hidden by default, can be shown to kick viewers (optional feature)
User Flow:
- Page loads → Broadcaster's encoder appears
- Broadcaster clicks "Start Call" → Connection to backend is established
- Broadcaster clicks "Start Broadcast" → Broadcast begins
- Viewer joins →
streamAddedevent fires - Broadcaster sees viewer's stream (bidirectional streaming)
<!--
Native Frame Private Broadcaster - Vanilla JavaScript Implementation
This HTML file demonstrates an advanced implementation of bidirectional streaming
using the @video/video-client-core library with vanilla JavaScript, from the
broadcaster's perspective.
What is Direct Streaming?
Direct Streaming enables bidirectional streaming where a broadcaster can accept
viewer requests to join the call and stream back to the broadcaster. This creates
a 1:1 interactive connection, different from standard broadcasting where viewers
only receive the stream.
Key Use Cases:
- 1:1 consultations (doctor-patient, tutor-student)
- Interactive Q&A sessions
- Live customer support with video
- Interview or audition streams
The direct streaming broadcaster application:
- Creates a call and broadcasts their stream
- Waits for viewer to request to join
- Receives streamAdded event when viewer joins
- Displays viewer's incoming stream
- Can kick viewers from the call
Prerequisites:
This is an ADVANCED implementation that assumes familiarity with:
- Basic encoder setup (see /broadcasting-a-livestream/set-up-a-livestream-video)
- Call and broadcast concepts (see /broadcasting-a-livestream/set-up-a-livestream-video)
- Stream event handling patterns
Required Setup:
1. Obtain authentication token with "broadcaster" scope
2. Get your stream key and backend endpoint from Native Frame
3. Load the video-client-core library via CDN
4. Include the necessary JavaScript modules
-->
<!doctype html>
<html>
<head>
<!--
Import Map Configuration
This configures module resolution for ES modules. The "vdc-cdn" alias
points to the video-client-core library hosted on the Native Frame CDN.
You can update the version number in the URL to use a different version
of the library. Check https://cdn.nativeframe.com/ for available versions.
-->
<script type="importmap">
{
"imports": {
"vdc-cdn": "https://cdn.nativeframe.com/video-client-core-13.2.0.js"
}
}
</script>
<!--
JavaScript Module Imports
These modules must be loaded in order:
1. auth.js - Handles authentication with the Native Frame backend
2. encoder-utils.js - Provides helper functions for setting up the broadcaster's encoder
3. direct-streaming-broadcaster.js - Main application logic for direct streaming
This implementation uses encoder concepts from the basic encoder example.
-->
<script type="module" src="/js/auth.js"></script>
<script type="module" src="/js/encoder-utils.js"></script>
<script type="module" src="/js/direct-streaming-broadcaster.js"></script>
<!--
Styling for Bidirectional Streaming Layout
The page uses a vertical layout with:
- Broadcaster section (top): Shows broadcaster's own encoder and controls
- Viewer section (bottom): Shows viewer's incoming stream
This layout makes it clear which video belongs to whom in the 1:1 session.
-->
<style>
body {
margin: 0;
padding: 0;
}
.container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
width: 100%;
max-width: 520px;
margin: 0 auto;
}
.video-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
}
.video-wrapper {
width: 100%;
max-width: 520px;
}
video {
display: block;
width: 100%;
height: 293px;
background: black;
object-fit: contain;
}
.controls {
width: 100%;
max-width: 520px;
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.hidden {
display: none;
}
</style>
</head>
<body>
<!--
Bidirectional Streaming UI Structure
The page is organized into two main sections:
1. Broadcaster Section (Top):
- encoderContainer: Will be populated with broadcaster's video preview and device controls
- callBtn: Creates/ends the call connection
- broadcastBtn: Starts/stops the broadcaster's broadcast
2. Viewer Section (Bottom):
- viewerVideo: Displays the viewer's incoming stream (when viewer joins)
- kickViewerBtn: Hidden for now, but can be shown to kick viewers (optional feature)
User Flow:
1. Page loads → Broadcaster's encoder appears
2. Broadcaster clicks "Start Call" → Connection to backend is established
3. Broadcaster clicks "Start Broadcast" → Broadcast begins
4. Viewer joins → streamAdded event fires, viewer's stream appears
5. Both parties can now see each other (bidirectional streaming)
-->
<div class="container">
<!-- Broadcaster Section - Where broadcaster's own video appears -->
<div>
<h3>Broadcaster</h3>
<!-- Broadcaster's video preview and device controls will be injected here -->
<div id="encoderContainer"></div>
<!-- Button to start/stop call (hidden until encoder is set up) -->
<button id="callBtn" style="display: none;">Start Call</button>
<!-- Button to start/stop broadcast (hidden until call is created) -->
<button id="broadcastBtn" style="display: none;"></button>
</div>
<!-- Viewer Section - Where viewer's video will appear -->
<div>
<h3>Viewer</h3>
<!-- Viewer's stream will be displayed here when they join -->
<video id="viewerVideo" style="background: black; height: 293px; width: 100%;"></video>
<!-- Button to kick viewer (hidden by default, can be shown when needed) -->
<button id="kickViewerBtn" class="hidden">Kick Viewer</button>
</div>
</div>
</div>
</body>
</html>
JavaScript
Main Application Logic (private-broadcaster.js)
/**
* Native Frame Direct Streaming Broadcaster - Main Application
*
* This is an ADVANCED implementation demonstrating bidirectional streaming from
* the broadcaster's perspective. The broadcaster creates a call, broadcasts their
* stream, and can accept viewers who stream back to them. This creates a 1:1
* interactive connection.
*
* Prerequisites:
* This implementation assumes familiarity with:
* - Basic encoder setup (see /broadcasting-a-livestream/set-up-a-livestream-video)
* - Call and broadcast concepts (see /broadcasting-a-livestream/set-up-a-livestream-video)
* - Stream event handling patterns
*
* Flow:
* 1. Page loads → Encoder is set up with camera/mic access
* 2. Broadcaster clicks "Start Call" → Call is created
* 3. Broadcaster clicks "Start Broadcast" → Broadcast begins
* 4. Viewer joins → streamAdded event fires
* 5. Broadcaster sees viewer's stream (bidirectional streaming)
* 6. Broadcaster can kick viewer from call
*
* Key Differences from Basic Encoder:
* - Listens for streamAdded event to receive viewer streams
* - Creates player for incoming viewer streams
* - Handles streamRemoved event for cleanup
* - Can kick viewers from the call
*/
import { requestEncoder } from './encoder-utils.js';
import { setAuthClient, backendEndpoint, streamKey, broadcasterToken } from './auth.js';
import { createCall, requestPlayer } from 'vdc-cdn';
/**
* Application State Variables
*
* These module-level variables maintain the state of the direct streaming broadcaster:
*
* - authClient: Authenticated client for API requests (with "broadcaster" scope)
* - mediaStreamController: Manages broadcaster's camera and microphone
* - previewPlayer: Displays broadcaster's own video preview
* - call: The WebRTC call instance (created by broadcaster)
* - viewerPlayer: Player displaying viewer's incoming stream
* - broadcast: Broadcaster's broadcast instance (streams broadcaster to viewers)
*
* All are initialized to null and populated during the application lifecycle.
*/
let authClient = null;
let mediaStreamController = null;
let previewPlayer = null;
let call = null;
let viewerPlayer = null;
let broadcast = null;
/**
* Handle Stream Added Event (Core Logic for Receiving Viewer Streams)
*
* This is the KEY PATTERN for direct streaming from the broadcaster's side.
* When a viewer joins the call and starts broadcasting, this event fires.
*
* The function:
* 1. Creates a player for the viewer's stream
* 2. Attaches the player to the viewer video element
* 3. Stores the player reference for later cleanup
*
* @param {Object} ev - The streamAdded event
* @param {Stream} ev.stream - The viewer's media stream
* @param {Peer} ev.peer - The viewer's peer information
* @param {string} ev.streamName - The name of the stream (usually "default")
*
* Important: This is a 1:1 connection pattern. For multiple viewers, you would
* need to manage an array of viewer players instead of a single viewerPlayer variable.
*/
async function handleStreamAdded(ev) {
// Request a player using the viewer's stream from the event
const newPlayer = await requestPlayer(ev.stream, { autoPlay: true, muted: false });
// Get the video element where viewer's stream will be displayed
const video = document.getElementById('viewerVideo');
// Attach the player to the video element
newPlayer.attachTo(video);
// Store the viewer's player for later cleanup
viewerPlayer = newPlayer;
}
/**
* Attach Event Handlers to Call
*
* Sets up event listeners for call-related events. These events allow the
* broadcaster to respond to viewer actions and connection changes.
*
* @param {Call} c - The call instance to attach handlers to
*
* Events Handled:
* 1. streamAdded - Fired when a viewer joins and starts broadcasting
* 2. streamRemoved - Fired when a viewer leaves or is kicked
*/
function attachCallHandlers(c) {
/**
* Stream Added Event
*
* Fired when a viewer joins the call and starts broadcasting their stream.
* This is the key event that enables bidirectional streaming.
*/
c.on("streamAdded", handleStreamAdded);
/**
* Stream Removed Event
*
* Fired when a viewer:
* - Disconnects from the call
* - Is kicked by the broadcaster
* - Stops broadcasting
*
* Cleanup process:
* 1. Dispose of the viewer's player
* 2. Clear the player reference
*/
c.on("streamRemoved", () => {
viewerPlayer?.dispose();
viewerPlayer = null;
});
/**
* Page Unload Cleanup
*
* Ensures proper cleanup when the broadcaster closes the page.
* This prevents resource leaks and properly closes connections.
*/
window.addEventListener("beforeunload", () => {
c.removeAllListeners();
c.dispose();
});
}
/**
* Toggle Broadcaster's Broadcast
*
* Starts or stops the broadcaster's broadcast stream.
* This function controls whether viewers can see the broadcaster.
*
* @param {Event} event - Click event from the broadcast button
*
* Important: A call must be active before broadcasting can start.
*
* The streamName parameter identifies the stream:
* - "default": Main camera/microphone feed
* - Other names can be used for additional streams (e.g., screen sharing)
*/
async function toggleBroadcast(event) {
if (broadcast == null) {
// Start broadcasting broadcaster's stream to viewers
event.target.disabled = true;
broadcast = await call.broadcast(mediaStreamController, { streamName: "default" });
event.target.textContent = "Stop Broadcast";
event.target.setAttribute("data-call-id", call.id);
document.getElementById("broadcastBtn").style.display = "block";
event.target.disabled = false;
} else {
// Stop broadcasting
event.target.disabled = true;
broadcast.dispose("broadcast disposed via toggleBroadcast()");
broadcast = null;
event.target.textContent = "Start Broadcast";
document.getElementById("broadcastBtn").style.display = "none";
event.target.disabled = false;
}
}
/**
* Toggle Call Connection
*
* Creates or terminates the connection to the Native Frame backend.
* The broadcaster must create a call before they can start broadcasting.
*
* @param {Event} event - Click event from the call button
*
* Call Options:
* - user: Broadcaster identification (userId and displayName)
* - streamKey: Unique stream identifier (from Native Frame)
* - backendEndpoints: Array of backend URLs (with automatic failover)
* - auth: Authentication client (with "broadcaster" scope)
*
* Key Pattern: After creating the call, we immediately attach event handlers
* to listen for incoming viewer streams.
*/
async function toggleCall(event) {
if (call == null) {
// Create call with broadcaster options
const callOptions = {
user: { userId: "123", displayName: "John Doe" },
streamKey,
backendEndpoints: [backendEndpoint],
auth: authClient,
};
call = await createCall(callOptions);
// Attach event handlers to listen for viewer streams
attachCallHandlers(call);
// Update UI to reflect call state
event.target.textContent = "Stop Call";
document.getElementById("broadcastBtn").textContent = "Start Broadcast";
document.getElementById("broadcastBtn").style.display = "block";
} else {
// End call and cleanup
call.dispose("call disposed via callState toggleCall()");
call = null;
event.target.textContent = "Start Call";
document.getElementById("broadcastBtn").style.display = "none";
}
}
/**
* Initialize the Direct Streaming Broadcaster Application
*
* This is the main initialization function that sets up the broadcaster.
* It runs when the page loads and performs these steps:
*
* 1. Creates an authenticated client using the broadcaster token (must have "broadcaster" scope)
* 2. Requests camera/mic access and creates preview player
* 3. Wires up button event handlers for call and broadcast controls
* 4. Shows the "Start Call" button
* 5. Registers cleanup handlers
*
* Key Pattern: The broadcaster CREATES the call (unlike viewer who JOINS a call).
* After creating the call, the broadcaster can accept viewers who stream back.
*/
async function init() {
// Create authenticated client for API requests (must have "broadcaster" scope)
authClient = await setAuthClient(broadcasterToken);
// Request encoder (see encoder-utils.js for details)
// This grants camera/mic access and creates preview player
const [msc, preview] = await requestEncoder();
mediaStreamController = msc;
previewPlayer = preview;
// Wire up button event handlers
document.getElementById("callBtn").onclick = toggleCall;
document.getElementById("broadcastBtn").onclick = toggleBroadcast;
// Show the call button (first step in user flow)
document.getElementById("callBtn").style.display = "block";
// Register cleanup handler for page unload
disposeOnBeforeUnload();
}
/**
* Clean Up All Resources (Centralized Cleanup)
*
* Disposes of all resources when the broadcaster needs to disconnect.
* This is called when the page is hidden (user navigates away or closes tab).
*
* Cleanup process:
* 1. Dispose of broadcaster's media devices (camera/microphone)
* 2. Dispose of broadcaster's preview player
* 3. Dispose of the call connection (this also disposes the broadcast)
* 4. Dispose of viewer's player
* 5. Clear all references
*
* Important: Always dispose of video-client resources when done to:
* - Release camera/microphone access
* - Close WebRTC connections
* - Stop media streams
* - Free up memory
*/
function dispose() {
if (document.hidden) {
// Dispose of broadcaster's media devices
mediaStreamController?.dispose();
mediaStreamController = null;
// Dispose of broadcaster's preview player
previewPlayer?.dispose();
previewPlayer = null;
// Dispose of the call connection (this also disposes the broadcast)
call?.dispose();
call = null;
// Dispose of viewer's player
viewerPlayer?.dispose();
viewerPlayer = null;
// Clear auth client
authClient = null;
}
}
/**
* Clean Up on Page Unload
*
* Registers a cleanup handler that runs when the page is about to unload.
* This ensures all resources are properly released when the broadcaster
* navigates away or closes the tab.
*/
function disposeOnBeforeUnload() {
window.addEventListener('beforeunload', () => {
dispose();
});
}
/**
* Application Entry Point
*
* This runs when the page finishes loading. It calls init() to set up
* the direct streaming broadcaster.
*/
window.onload = async () => {
await init();
};
Full Code
Private Broadcaster JS + HTML
<!--
Native Frame Private Broadcaster - Vanilla JavaScript Implementation
This HTML file demonstrates an advanced implementation of bidirectional streaming
using the @video/video-client-core library with vanilla JavaScript, from the
broadcaster's perspective.
What is Direct Streaming?
Direct Streaming enables bidirectional streaming where a broadcaster can accept
viewer requests to join the call and stream back to the broadcaster. This creates
a 1:1 interactive connection, different from standard broadcasting where viewers
only receive the stream.
Key Use Cases:
- 1:1 consultations (doctor-patient, tutor-student)
- Interactive Q&A sessions
- Live customer support with video
- Interview or audition streams
The direct streaming broadcaster application:
- Creates a call and broadcasts their stream
- Waits for viewer to request to join
- Receives streamAdded event when viewer joins
- Displays viewer's incoming stream
- Can kick viewers from the call
Prerequisites:
This is an ADVANCED implementation that assumes familiarity with:
- Basic encoder setup (see /broadcasting-a-livestream/set-up-a-livestream-video)
- Call and broadcast concepts (see /broadcasting-a-livestream/set-up-a-livestream-video)
- Stream event handling patterns
Required Setup:
1. Obtain authentication token with "broadcaster" scope
2. Get your stream key and backend endpoint from Native Frame
3. Load the video-client-core library via CDN
4. Include the necessary JavaScript modules
-->
<!doctype html>
<html>
<head>
<!--
Import Map Configuration
This configures module resolution for ES modules. The "vdc-cdn" alias
points to the video-client-core library hosted on the Native Frame CDN.
You can update the version number in the URL to use a different version
of the library. Check https://cdn.nativeframe.com/ for available versions.
-->
<script type="importmap">
{
"imports": {
"vdc-cdn": "https://cdn.nativeframe.com/video-client-core-13.2.0.js"
}
}
</script>
<!--
JavaScript Module Imports
These modules must be loaded in order:
1. auth.js - Handles authentication with the Native Frame backend
2. encoder-utils.js - Provides helper functions for setting up the broadcaster's encoder
3. direct-streaming-broadcaster.js - Main application logic for direct streaming
This implementation uses encoder concepts from the basic encoder example.
-->
<script type="module" src="/js/auth.js"></script>
<script type="module" src="/js/encoder-utils.js"></script>
<script type="module" src="/js/direct-streaming-broadcaster.js"></script>
<!--
Styling for Bidirectional Streaming Layout
The page uses a vertical layout with:
- Broadcaster section (top): Shows broadcaster's own encoder and controls
- Viewer section (bottom): Shows viewer's incoming stream
This layout makes it clear which video belongs to whom in the 1:1 session.
-->
<style>
body {
margin: 0;
padding: 0;
}
.container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
width: 100%;
max-width: 520px;
margin: 0 auto;
}
.video-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
}
.video-wrapper {
width: 100%;
max-width: 520px;
}
video {
display: block;
width: 100%;
height: 293px;
background: black;
object-fit: contain;
}
.controls {
width: 100%;
max-width: 520px;
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.hidden {
display: none;
}
</style>
</head>
<body>
<!--
Bidirectional Streaming UI Structure
The page is organized into two main sections:
1. Broadcaster Section (Top):
- encoderContainer: Will be populated with broadcaster's video preview and device controls
- callBtn: Creates/ends the call connection
- broadcastBtn: Starts/stops the broadcaster's broadcast
2. Viewer Section (Bottom):
- viewerVideo: Displays the viewer's incoming stream (when viewer joins)
- kickViewerBtn: Hidden for now, but can be shown to kick viewers (optional feature)
User Flow:
1. Page loads → Broadcaster's encoder appears
2. Broadcaster clicks "Start Call" → Connection to backend is established
3. Broadcaster clicks "Start Broadcast" → Broadcast begins
4. Viewer joins → streamAdded event fires, viewer's stream appears
5. Both parties can now see each other (bidirectional streaming)
-->
<div class="container">
<!-- Broadcaster Section - Where broadcaster's own video appears -->
<div>
<h3>Broadcaster</h3>
<!-- Broadcaster's video preview and device controls will be injected here -->
<div id="encoderContainer"></div>
<!-- Button to start/stop call (hidden until encoder is set up) -->
<button id="callBtn" style="display: none;">Start Call</button>
<!-- Button to start/stop broadcast (hidden until call is created) -->
<button id="broadcastBtn" style="display: none;"></button>
</div>
<!-- Viewer Section - Where viewer's video will appear -->
<div>
<h3>Viewer</h3>
<!-- Viewer's stream will be displayed here when they join -->
<video id="viewerVideo" style="background: black; height: 293px; width: 100%;"></video>
<!-- Button to kick viewer (hidden by default, can be shown when needed) -->
<button id="kickViewerBtn" class="hidden">Kick Viewer</button>
</div>
</div>
</div>
</body>
</html>
/**
* Native Frame Direct Streaming Broadcaster - Main Application
*
* This is an ADVANCED implementation demonstrating bidirectional streaming from
* the broadcaster's perspective. The broadcaster creates a call, broadcasts their
* stream, and can accept viewers who stream back to them. This creates a 1:1
* interactive connection.
*
* Prerequisites:
* This implementation assumes familiarity with:
* - Basic encoder setup (see /broadcasting-a-livestream/set-up-a-livestream-video)
* - Call and broadcast concepts (see /broadcasting-a-livestream/set-up-a-livestream-video)
* - Stream event handling patterns
*
* Flow:
* 1. Page loads → Encoder is set up with camera/mic access
* 2. Broadcaster clicks "Start Call" → Call is created
* 3. Broadcaster clicks "Start Broadcast" → Broadcast begins
* 4. Viewer joins → streamAdded event fires
* 5. Broadcaster sees viewer's stream (bidirectional streaming)
* 6. Broadcaster can kick viewer from call
*
* Key Differences from Basic Encoder:
* - Listens for streamAdded event to receive viewer streams
* - Creates player for incoming viewer streams
* - Handles streamRemoved event for cleanup
* - Can kick viewers from the call
*/
import { requestEncoder } from './encoder-utils.js';
import { setAuthClient, backendEndpoint, streamKey, broadcasterToken } from './auth.js';
import { createCall, requestPlayer } from 'vdc-cdn';
/**
* Application State Variables
*
* These module-level variables maintain the state of the direct streaming broadcaster:
*
* - authClient: Authenticated client for API requests (with "broadcaster" scope)
* - mediaStreamController: Manages broadcaster's camera and microphone
* - previewPlayer: Displays broadcaster's own video preview
* - call: The WebRTC call instance (created by broadcaster)
* - viewerPlayer: Player displaying viewer's incoming stream
* - broadcast: Broadcaster's broadcast instance (streams broadcaster to viewers)
*
* All are initialized to null and populated during the application lifecycle.
*/
let authClient = null;
let mediaStreamController = null;
let previewPlayer = null;
let call = null;
let viewerPlayer = null;
let broadcast = null;
/**
* Handle Stream Added Event (Core Logic for Receiving Viewer Streams)
*
* This is the KEY PATTERN for direct streaming from the broadcaster's side.
* When a viewer joins the call and starts broadcasting, this event fires.
*
* The function:
* 1. Creates a player for the viewer's stream
* 2. Attaches the player to the viewer video element
* 3. Stores the player reference for later cleanup
*
* @param {Object} ev - The streamAdded event
* @param {Stream} ev.stream - The viewer's media stream
* @param {Peer} ev.peer - The viewer's peer information
* @param {string} ev.streamName - The name of the stream (usually "default")
*
* Important: This is a 1:1 connection pattern. For multiple viewers, you would
* need to manage an array of viewer players instead of a single viewerPlayer variable.
*/
async function handleStreamAdded(ev) {
// Request a player using the viewer's stream from the event
const newPlayer = await requestPlayer(ev.stream, { autoPlay: true, muted: false });
// Get the video element where viewer's stream will be displayed
const video = document.getElementById('viewerVideo');
// Attach the player to the video element
newPlayer.attachTo(video);
// Store the viewer's player for later cleanup
viewerPlayer = newPlayer;
}
/**
* Attach Event Handlers to Call
*
* Sets up event listeners for call-related events. These events allow the
* broadcaster to respond to viewer actions and connection changes.
*
* @param {Call} c - The call instance to attach handlers to
*
* Events Handled:
* 1. streamAdded - Fired when a viewer joins and starts broadcasting
* 2. streamRemoved - Fired when a viewer leaves or is kicked
*/
function attachCallHandlers(c) {
/**
* Stream Added Event
*
* Fired when a viewer joins the call and starts broadcasting their stream.
* This is the key event that enables bidirectional streaming.
*/
c.on("streamAdded", handleStreamAdded);
/**
* Stream Removed Event
*
* Fired when a viewer:
* - Disconnects from the call
* - Is kicked by the broadcaster
* - Stops broadcasting
*
* Cleanup process:
* 1. Dispose of the viewer's player
* 2. Clear the player reference
*/
c.on("streamRemoved", () => {
viewerPlayer?.dispose();
viewerPlayer = null;
});
/**
* Page Unload Cleanup
*
* Ensures proper cleanup when the broadcaster closes the page.
* This prevents resource leaks and properly closes connections.
*/
window.addEventListener("beforeunload", () => {
c.removeAllListeners();
c.dispose();
});
}
/**
* Toggle Broadcaster's Broadcast
*
* Starts or stops the broadcaster's broadcast stream.
* This function controls whether viewers can see the broadcaster.
*
* @param {Event} event - Click event from the broadcast button
*
* Important: A call must be active before broadcasting can start.
*
* The streamName parameter identifies the stream:
* - "default": Main camera/microphone feed
* - Other names can be used for additional streams (e.g., screen sharing)
*/
async function toggleBroadcast(event) {
if (broadcast == null) {
// Start broadcasting broadcaster's stream to viewers
event.target.disabled = true;
broadcast = await call.broadcast(mediaStreamController, { streamName: "default" });
event.target.textContent = "Stop Broadcast";
event.target.setAttribute("data-call-id", call.id);
document.getElementById("broadcastBtn").style.display = "block";
event.target.disabled = false;
} else {
// Stop broadcasting
event.target.disabled = true;
broadcast.dispose("broadcast disposed via toggleBroadcast()");
broadcast = null;
event.target.textContent = "Start Broadcast";
document.getElementById("broadcastBtn").style.display = "none";
event.target.disabled = false;
}
}
/**
* Toggle Call Connection
*
* Creates or terminates the connection to the Native Frame backend.
* The broadcaster must create a call before they can start broadcasting.
*
* @param {Event} event - Click event from the call button
*
* Call Options:
* - user: Broadcaster identification (userId and displayName)
* - streamKey: Unique stream identifier (from Native Frame)
* - backendEndpoints: Array of backend URLs (with automatic failover)
* - auth: Authentication client (with "broadcaster" scope)
*
* Key Pattern: After creating the call, we immediately attach event handlers
* to listen for incoming viewer streams.
*/
async function toggleCall(event) {
if (call == null) {
// Create call with broadcaster options
const callOptions = {
user: { userId: "123", displayName: "John Doe" },
streamKey,
backendEndpoints: [backendEndpoint],
auth: authClient,
};
call = await createCall(callOptions);
// Attach event handlers to listen for viewer streams
attachCallHandlers(call);
// Update UI to reflect call state
event.target.textContent = "Stop Call";
document.getElementById("broadcastBtn").textContent = "Start Broadcast";
document.getElementById("broadcastBtn").style.display = "block";
} else {
// End call and cleanup
call.dispose("call disposed via callState toggleCall()");
call = null;
event.target.textContent = "Start Call";
document.getElementById("broadcastBtn").style.display = "none";
}
}
/**
* Initialize the Direct Streaming Broadcaster Application
*
* This is the main initialization function that sets up the broadcaster.
* It runs when the page loads and performs these steps:
*
* 1. Creates an authenticated client using the broadcaster token (must have "broadcaster" scope)
* 2. Requests camera/mic access and creates preview player
* 3. Wires up button event handlers for call and broadcast controls
* 4. Shows the "Start Call" button
* 5. Registers cleanup handlers
*
* Key Pattern: The broadcaster CREATES the call (unlike viewer who JOINS a call).
* After creating the call, the broadcaster can accept viewers who stream back.
*/
async function init() {
// Create authenticated client for API requests (must have "broadcaster" scope)
authClient = await setAuthClient(broadcasterToken);
// Request encoder (see encoder-utils.js for details)
// This grants camera/mic access and creates preview player
const [msc, preview] = await requestEncoder();
mediaStreamController = msc;
previewPlayer = preview;
// Wire up button event handlers
document.getElementById("callBtn").onclick = toggleCall;
document.getElementById("broadcastBtn").onclick = toggleBroadcast;
// Show the call button (first step in user flow)
document.getElementById("callBtn").style.display = "block";
// Register cleanup handler for page unload
disposeOnBeforeUnload();
}
/**
* Clean Up All Resources (Centralized Cleanup)
*
* Disposes of all resources when the broadcaster needs to disconnect.
* This is called when the page is hidden (user navigates away or closes tab).
*
* Cleanup process:
* 1. Dispose of broadcaster's media devices (camera/microphone)
* 2. Dispose of broadcaster's preview player
* 3. Dispose of the call connection (this also disposes the broadcast)
* 4. Dispose of viewer's player
* 5. Clear all references
*
* Important: Always dispose of video-client resources when done to:
* - Release camera/microphone access
* - Close WebRTC connections
* - Stop media streams
* - Free up memory
*/
function dispose() {
if (document.hidden) {
// Dispose of broadcaster's media devices
mediaStreamController?.dispose();
mediaStreamController = null;
// Dispose of broadcaster's preview player
previewPlayer?.dispose();
previewPlayer = null;
// Dispose of the call connection (this also disposes the broadcast)
call?.dispose();
call = null;
// Dispose of viewer's player
viewerPlayer?.dispose();
viewerPlayer = null;
// Clear auth client
authClient = null;
}
}
/**
* Clean Up on Page Unload
*
* Registers a cleanup handler that runs when the page is about to unload.
* This ensures all resources are properly released when the broadcaster
* navigates away or closes the tab.
*/
function disposeOnBeforeUnload() {
window.addEventListener('beforeunload', () => {
dispose();
});
}
/**
* Application Entry Point
*
* This runs when the page finishes loading. It calls init() to set up
* the direct streaming broadcaster.
*/
window.onload = async () => {
await init();
};
Supporting JS
/**
* Authentication Module for Native Frame Encoder
*
* This module handles authentication with the Native Frame backend. It manages:
* - Creating authenticated clients for API requests
*
* Prerequisites:
* - JWT authentication token
*
*/
import { BaseAuthClient } from 'vdc-cdn';
/**
* Create an Authenticated Client
*
* This function creates a BaseAuthClient instance that will be used to authenticate
* all API requests when creating calls and broadcasts.
*
* @param {string} token - JWT authentication token (broadcasterToken or viewerToken)
* @returns {Promise<BaseAuthClient>} Authenticated client instance
* @throws {Error} If token is empty or invalid
*
* Usage:
* const authClient = await setAuthClient(broadcasterToken);
*
* The returned authClient is passed to createCall() to authenticate the connection.
*/
export async function setAuthClient(token) {
// Validate that a token was provided
if (token.length === 0) {
throw new Error("No JWT found");
}
// Create and return the authenticated client
// BaseAuthClient handles token validation and API request authentication
const auth = new BaseAuthClient(token);
return auth;
}
/**
* Encoder Utilities Module
*
* This module provides utility functions for setting up the video encoder UI.
* It handles:
* - Requesting access to camera and microphone
* - Creating the media stream controller
* - Setting up the preview player
* - Creating and managing the encoder UI elements
* - Attaching event handlers for device controls
*
* Key Concepts:
* - MediaStreamController: Manages access to camera/microphone and controls their state
* - PreviewPlayer: Displays the local video feed before broadcasting
* - MediaController: Global singleton that manages device enumeration and permissions
*/
import { requestPlayer, mediaController } from 'vdc-cdn'
/**
* Initialize Encoder with Media Devices
*
* This is the main initialization function for the encoder. It:
* 1. Requests camera and microphone permissions
* 2. Creates a MediaStreamController to manage the devices
* 3. Creates a PreviewPlayer to display the video
* 4. Builds the UI for device controls
* 5. Attaches event handlers for user interactions
*
* @returns {Promise<[MediaStreamController, PreviewPlayer]>} Array containing the controller and player
*
* Usage:
* const [mediaStreamController, previewPlayer] = await requestEncoder();
*
* The returned objects are used throughout the application:
* - mediaStreamController: Used to start/stop camera/mic and switch devices
* - previewPlayer: Used to display the video feed and must be disposed when done
*/
export async function requestEncoder() {
// Initialize the global media controller
// This requests browser permissions for camera and microphone access
await mediaController.init();
// Create a MediaStreamController instance
// This manages the actual media streams from the selected devices
const mediaStreamController = await mediaController.requestController();
// Enumerate available audio and video devices
// Returns arrays of available cameras and microphones
const [audioDevices, videoDevices] = getDevices();
// Create a preview player to display the local video feed
// autoPlay: true - automatically starts playing when attached
// muted: true - mutes the local preview (prevents audio feedback)
const previewPlayer = await requestPlayer(mediaStreamController, { autoPlay: true, muted: true });
// Create the video element and append it to the DOM
// Returns a reference to the video element
const video = appendEncoderHTMLToDOM();
// Attach the preview player to the video element
// This connects the media stream to the video tag for display
previewPlayer.attachTo(video);
// Set up click handlers for camera/mic toggle buttons and device selection
attachEncoderEventHandlers(mediaStreamController);
// Configure the initial devices (first available camera/mic)
// and populate the device selection dropdowns
setInitialDevices(mediaStreamController, audioDevices, videoDevices);
// Return both objects for use in the main application
return [mediaStreamController, previewPlayer];
}
/**
* Generate HTML for Encoder UI
*
* Creates the HTML structure for the video preview and device controls.
* This includes:
* - Video element for displaying the preview
* - Camera toggle button
* - Microphone toggle button
* - Video device selector dropdown
* - Audio device selector dropdown
*
* @returns {string} HTML string containing the encoder UI structure
*
* The generated HTML will be injected into the #encoderContainer div.
*/
export function createEncoderHTML() {
return `
<div class="video-wrapper">
<!-- Video element where the preview will be displayed -->
<video id="preview-player" style="height: 100%; width: 100%"></video>
<div class="controls">
<!-- Toggle camera on/off -->
<button id="cameraBtn"></button>
<!-- Toggle microphone on/off -->
<button id="micBtn"></button>
<!-- Select video input device (camera) -->
<label for="videoDeviceSelect">
Choose video device:
<select id="videoDeviceSelect" name="videoDeviceSelect"></select>
</label>
<!-- Select audio input device (microphone) -->
<label for="audioDeviceSelect">
Choose audio device:
<select id="audioDeviceSelect" name="audioDeviceSelect"></select>
</label>
</div>
</div>
`;
}
/**
* Append Encoder HTML to DOM
*
* Injects the encoder UI into the page and returns a reference to the video element.
* This function:
* 1. Finds the encoder container div
* 2. Inserts the encoder HTML
* 3. Returns the video element for player attachment
*
* @returns {HTMLVideoElement} Reference to the video element
*
* The video element is returned so the PreviewPlayer can be attached to it.
*/
function appendEncoderHTMLToDOM() {
// Find the container where we'll inject the encoder UI
const encoderContainer = document.getElementById("encoderContainer");
// Insert the encoder HTML into the container
encoderContainer.insertAdjacentHTML("beforeend", createEncoderHTML());
// Return reference to the video element
return document.getElementById("preview-player");
}
/**
* Get Available Media Devices
*
* Retrieves lists of available audio and video input devices from the mediaController.
* This includes all cameras and microphones that the user has granted permission to access.
*
* @returns {[MediaDeviceInfo[], MediaDeviceInfo[]]} Array containing audio and video devices
*
* Each device object contains:
* - deviceId: Unique identifier for the device
* - label: Human-readable name (e.g., "Built-in Camera", "External Microphone")
* - kind: Type of device ("audioinput" or "videoinput")
*/
export function getDevices() {
const audioDevices = mediaController.audioDevices();
const videoDevices = mediaController.videoDevices();
return [audioDevices, videoDevices];
}
/**
* Configure Initial Devices
*
* Sets up the encoder with the first available camera and microphone, and
* populates the device selection dropdowns with all available options.
*
* This function:
* 1. Selects the first available audio/video device
* 2. Populates dropdown menus with all available devices
* 3. Sets initial button text based on device state
*
* @param {MediaStreamController} mediaStreamController - Controller managing the media streams
* @param {MediaDeviceInfo[]} audioDevices - Array of available audio input devices
* @param {MediaDeviceInfo[]} videoDevices - Array of available video input devices
*/
export function setInitialDevices(mediaStreamController, audioDevices, videoDevices) {
const audioDeviceSelect = document.getElementById("audioDeviceSelect");
const videoDeviceSelect = document.getElementById("videoDeviceSelect");
// Configure audio devices
if (audioDevices.length > 0) {
// Set the first audio device as the active microphone
mediaStreamController.audioDeviceId = audioDevices[0].deviceId;
// Populate the audio device dropdown with all available microphones
audioDevices.forEach((item) => {
audioDeviceSelect.options[audioDeviceSelect.options.length] = new Option(item.label, item.deviceId);
});
}
// Configure video devices
if (mediaController.videoDevices().length > 0) {
// Set the first video device as the active camera
mediaStreamController.videoDeviceId = videoDevices[0].deviceId;
// Populate the video device dropdown with all available cameras
videoDevices.forEach((item) => {
videoDeviceSelect.options[videoDeviceSelect.options.length] = new Option(item.label, item.deviceId);
});
}
// Set initial button text based on current device state
document.getElementById("cameraBtn").textContent = mediaStreamController.videoPaused
? "Enable Camera"
: "Disable Camera";
document.getElementById("micBtn").textContent = mediaStreamController.audioMuted ? "Enable Mic" : "Disable Mic";
}
/**
* Attach Event Handlers to Encoder Controls
*
* Sets up click and change event handlers for all encoder UI controls.
* This enables users to:
* - Toggle camera on/off
* - Toggle microphone on/off
* - Switch between available cameras
* - Switch between available microphones
*
* @param {MediaStreamController} mediaStreamController - Controller to manipulate
*/
function attachEncoderEventHandlers(mediaStreamController) {
/**
* Toggle Camera On/Off
*
* Pauses or resumes the video track. When paused, the camera is disabled
* and the video feed stops. The camera remains allocated to prevent
* other applications from accessing it.
*
* @param {Event} event - Click event from the camera button
*/
function toggleCamera(event) {
// Toggle the videoPaused state
mediaStreamController.videoPaused = !mediaStreamController.videoPaused;
// Update button text to reflect new state
event.target.textContent = mediaStreamController.videoPaused ? "Enable Camera" : "Disable Camera";
}
/**
* Toggle Microphone On/Off
*
* Mutes or unmutes the audio track. When muted, the microphone continues
* to capture audio but it won't be included in the broadcast.
*
* @param {Event} event - Click event from the microphone button
*/
function toggleMic(event) {
// Toggle the audioMuted state
mediaStreamController.audioMuted = !mediaStreamController.audioMuted;
// Update button text to reflect new state
event.target.textContent = mediaStreamController.audioMuted ? "Enable Mic" : "Disable Mic";
}
/**
* Handle Video Device Selection
*
* Switches to a different camera when the user selects one from the dropdown.
* The mediaStreamController automatically handles stopping the old device
* and starting the new one.
*
* @param {Event} ev - Change event from the video device select dropdown
*/
function handleVideoDeviceSelect(ev) {
// Update the active video device
// This triggers the controller to switch cameras
mediaStreamController.videoDeviceId = ev.target.value;
}
/**
* Handle Audio Device Selection
*
* Switches to a different microphone when the user selects one from the dropdown.
* The mediaStreamController automatically handles stopping the old device
* and starting the new one.
*
* @param {Event} ev - Change event from the audio device select dropdown
*/
function handleAudioDeviceSelect(ev) {
// Update the active audio device
// This triggers the controller to switch microphones
mediaStreamController.audioDeviceId = ev.target.value;
}
/**
* Wire Up Event Listeners
*
* Connect the handler functions to the corresponding DOM elements
*/
document.getElementById("cameraBtn").onclick = toggleCamera;
document.getElementById("micBtn").onclick = toggleMic;
document.getElementById("videoDeviceSelect").onchange = handleVideoDeviceSelect;
document.getElementById("audioDeviceSelect").onchange = handleAudioDeviceSelect;
}
Key Concepts
Bidirectional Streaming
Bidirectional streaming enables two-way video communication:
- Broadcaster creates call: Via
createCall()with broadcaster options - Broadcaster broadcasts: Their camera/mic stream via
call.broadcast() - Viewer joins call: Via player's WebRTC driver (automatically)
- Viewer broadcasts back: Their camera/mic stream via
call.broadcast() - 1:1 connection: Both parties interact in real-time
- Single call: All communication happens through one WebRTC call
streamAdded Event
The streamAdded event is crucial for direct streaming:
- Fired by: Call when a viewer joins and starts broadcasting
- Provides: The viewer's media stream
- Enables: Broadcaster to display viewer's video
- Different from: Creating a player for a manifest (passive viewing)
Call vs Broadcast
Understanding the distinction:
- Call: The connection to the Native Frame backend (created by broadcaster)
- Broadcast: Sending media through the call (broadcaster's camera/mic to viewers)
- Broadcaster flow: Create call → Start broadcast → Listen for viewer streams
Event Handling
The broadcaster responds to viewer actions:
streamAdded:
- Viewer joins the call and starts broadcasting
- Broadcaster creates player for viewer's stream
- Triggers bidirectional streaming
streamRemoved:
- Viewer disconnects or is kicked
- Broadcaster disposes of viewer's player
- Triggers cleanup
Authentication Scope
Direct Streaming Broadcaster:
- Requires "broadcaster" scope
- Can create calls and broadcasts
- Can receive viewer streams
- Can kick viewers from call
Direct Streaming Viewer:
- Uses "private-viewer" scope
- Can join call and broadcast back
- Different from "viewer" scope (which cannot broadcast)
Basic Broadcaster:
- Uses "broadcaster" scope
- Creates calls and broadcasts
- Typically doesn't receive viewer streams (one-way)
Direct Streaming Broadcaster vs Other Patterns
Direct Streaming Broadcaster:
- Creates call with broadcaster options
- Broadcasts their stream
- Listens for
streamAddedevent - Creates player for incoming viewer streams
- Bidirectional communication
- 1:1 interactive session
Basic Broadcaster:
- Creates call and broadcasts
- Does not listen for viewer streams
- One-way communication
- Multiple viewers can watch
Direct Streaming Viewer:
- Joins call via manifest player with WebRTC driver
- Automatically joins call via
joinedCallevent - Sets up encoder to stream back
- Bidirectional communication
- 1:1 interactive session
Next Steps
To learn more about advanced streaming concepts:
- Explore Broadcasting a Livestream
- Learn about Private Viewers