Skip to main content

Private Viewer

Join a private stream and broadcast back to the host.

What is Private Viewing?

The private viewing differs from the private broadcasting in several important ways:

  1. Call Joining vs Creation

    • Viewer: Joins existing call via Player's joinedCall event
    • Broadcaster: Creates the call
  2. Event Handling

    • Viewer: Responds to viewerKicked, callEnded, peerRemoved
    • Broadcaster: Handles streamAdded, streamRemoved (manages viewers)
  3. Setup Flow

    • Viewer: Watch first via manifestUrl → Setup button → Stream back
    • Broadcaster: Create call → Accept viewer requests
  4. Authentication Scope

    • Viewer: "private-viewer" scope
    • Broadcaster: "broadcaster" scope

Prerequisites

These docs are for an advanced implementation and assume 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:

Private Viewer Component

The Private Viewer component demonstrates:

  1. Automatic Call Joining - Uses Player's joinedCall event to obtain call instance (doesn't create call)
  2. Setup Flow - Watch broadcaster first → Click setup → Stream back
  3. Event Handling - React to viewerKicked, callEnded, peerRemoved events
  4. Bidirectional Streaming - View broadcaster AND stream back simultaneously

The component uses:

  • hooks: useAuthClient for authentication and media setup
  • context: MediaStreamControllerAPIProvider, PlayerAPIProvider for sharing instances
  • PreviewPlayer: Viewer's video preview with device controls
  • Player: Displays broadcaster's manifest stream with WebRTC driver
  • CallControls: Manages broadcast state (start/stop streaming back)

State

This component follows the same pattern for setting up a preview player that we have demonstrated in Broadcasting a Livestream.

Key difference: The viewer introduces state specific to the private viewing flow:

  • call: Obtained from Player's joinedCall event (NOT created directly by viewer)
  • callJoined: Tracks when player successfully joins the call (enables setup button)
  • viewerKicked: Tracks if broadcaster explicitly removed this viewer
  • callClosed: Tracks if the call has ended
/**
* STATE MANAGEMENT
*
* The viewer component manages several pieces of state for the direct streaming flow.
*/

/**
* VIEWER STATUS STATE
* - viewerKicked: Tracks if broadcaster kicked this viewer
* - callJoined: Tracks if viewer has joined the call
* - callClosed: Tracks if call has ended
*/
const [viewerKicked, setViewerKicked] = useState(false);
const [callJoined, setCallJoined] = useState(false);
const [callClosed, setCallClosed] = useState(false);

/**
* MEDIA STATE
* - previewPlayer: Viewer's own video preview (what they see of themselves)
* - mediaStreamController: Controls viewer's camera and microphone
*/
const [previewPlayer, setPreviewPlayer] = useState<types.PlayerAPI | null>(null);
const [mediaStreamController, setMediaStreamController] = useState<types.MediaStreamControllerAPI | null>(null);

/**
* CALL STATE
* - call: The active call instance (obtained from Player's joinedCall event)
*/
const [call, setCall] = useState<types.CallAPI | null>(null);

let initialized = false;

Automatic Call Joining (Core Logic)

This is the key difference from regular broadcasting. Viewers don't create calls—they join existing calls automatically through the Player component.

Key Pattern: joinedCall Event

When the Player connects to the broadcaster's manifest using the WebRTC driver, it automatically joins the call and fires the joinedCall event. This event provides the call instance that the viewer needs to stream back.

Event Flow:

  1. Player connects to broadcaster's manifest → WebRTC driver auto-joins call
  2. joinedCall event fires → Provides call instance
  3. We set callJoined to true → Enables "Setup Call With Broadcaster" button
  4. Viewer clicks setup → Grants camera/mic access
  5. Viewer starts broadcast → Broadcaster sees viewer's stream

Important: This is fundamentally different from the broadcaster pattern where calls are created explicitly.

const playerEventsMap = useMemo(() => ({
joinedCall: (ev: types.PlayerCompatEvents["joinedCall"]) => {
setCallJoined(true);
setCall(ev.call);
},
}), []);

Call Event Handling

Viewers need to respond to events fired by the broadcaster or when the call ends:

Events Handled:

  1. viewerKicked: Broadcaster explicitly removed this viewer → Show kicked message
  2. callEnded: Call ended by broadcaster → Cleanup
  3. peerRemoved: Broadcaster disconnected → Cleanup
  4. callClosed: System closed the call → Cleanup

All events trigger cleanup. The viewerKicked event additionally sets a flag to show a user-friendly kicked message.

/**
* CALL EVENT HANDLING
*
* Viewers need to respond to various call events that can occur during
* a direct streaming session. These events are fired by the broadcaster
* or when the call ends.
*
* EVENTS HANDLED:
* 1. callEnded: Natural end of the call by broadcaster
* 2. viewerKicked: Broadcaster explicitly kicked this viewer
* 3. peerRemoved: Broadcaster disconnected from the call
* 4. callClosed: Call was closed by the system
*
* All events trigger cleanup to release resources and update UI state.
* The viewerKicked event additionally sets a flag to show a kicked message.
*
* IMPORTANT: These events are specific to the viewer experience.
* The broadcaster has different event handling for managing viewers.
*/
useEffect(() => {
if (call == null) return;

const handleCallEnded = () => {
cleanupPage("Disposed by callEnded event");
};

const handleViewerKicked = () => {
cleanupPage("Disposed by viewerKicked event");
setViewerKicked(true);
};

const handlePeerRemoved = (ev: types.CallEvents["peerRemoved"]) => {
cleanupPage("Disposed by peerRemoved event");
};

const handleCallClosed = (ev: types.CallEvents["callClosed"]) => {
cleanupPage("Disposed by callClosed event");
};

// Attach event listeners
call.on("callClosed", handleCallClosed);
call.on("peerRemoved", handlePeerRemoved);
call.on("callEnded", handleCallEnded);
call.on("viewerKicked", handleViewerKicked);

// Cleanup: Remove event listeners when call changes or component unmounts
return () => {
call?.off("viewerKicked", handleViewerKicked);
call?.off("peerRemoved", handlePeerRemoved);
call?.off("callClosed", handleCallClosed);
call?.off("callEnded", handleCallEnded);
};

}, [cleanupPage, call]);

Cleanup Handler

Centralized cleanup function for all disconnect scenarios:

/**
* CLEANUP PAGE HANDLER
*
* Centralized cleanup function called when:
* - Viewer is kicked by broadcaster
* - Call ends
* - Peer (broadcaster) removes viewer
* - Component unmounts
*
* CLEANUP FLOW:
* 1. Dispose media stream controller (release camera/mic)
* 2. Dispose preview player (stop local video)
* 3. Dispose call (disconnect from session)
* 4. Reset state flags
* 5. Call parent's cleanup callback
*
* This ensures proper resource cleanup and prevents memory leaks.
*/
const cleanupPage = useCallback((message: string) => {
if (mediaStreamController != null) {
mediaStreamController.dispose(message);
setMediaStreamController(null);
}
if (previewPlayer != null) {
previewPlayer.dispose(message);
setPreviewPlayer(null);
}
if (call != null) {
call.dispose(message);
setCall(null);
}
setCallJoined(false);
setCallClosed(true);
cleanupCb?.();
}, [ mediaStreamController, previewPlayer, call]);

Render UI

The component uses a split-panel layout with conditional rendering based on setup state:

Layout:

  • Left Panel: Broadcaster's manifest player (passive viewing)
    • Automatically joins call via WebRTC driver
    • Fires joinedCall event with call instance
  • Right Panel: Conditional based on setup state:
    • Before setup: "Setup Call With Broadcaster" button (disabled until callJoined)
    • After setup: Viewer's preview player + broadcast controls

User Flow:

  1. Viewer watches broadcaster (left panel)
  2. Player joins call in background → joinedCall event fires
  3. Setup button becomes enabled
  4. Viewer clicks setup → Grants camera/mic access
  5. Encoder appears with broadcast controls
  6. Viewer starts broadcast → Broadcaster sees viewer
/**
* RENDER: CONDITIONAL UI STATES
*
* The viewer interface has three conditional states based on the session status.
*/

/**
* STATE 1: LOADING OR CALL CLOSED
*
* Show empty loading screen when:
* - manifestUrl is not available (can't watch broadcaster)
* - Call has been closed
*
* This is the initial state and the final state after cleanup.
*/
if (manifestUrl == null || callClosed) {
return <div className={classNames.loadingScreenClassName} />;
}

/**
* STATE 2: VIEWER KICKED
*
* Show kicked message when broadcaster has explicitly removed this viewer.
* This provides clear feedback to the user about why they were disconnected.
*/
if (viewerKicked) {
return <div className={classNames.viewerKickedClassName}>Viewer was kicked from the call by the broadcaster</div>;
}

/**
* STATE 3: ACTIVE VIEWING SESSION
*
* Split-panel layout for direct streaming:
*
* LEFT PANEL: Broadcaster's Stream
* - Player component showing broadcaster's manifest
* - Automatically joins call via joinedCall event
* - Provides call instance for streaming back
*
* RIGHT PANEL: Viewer's Interaction (Conditional)
* - Before setup: "Setup Call With Broadcaster" button
* - Disabled until call is joined (callJoined = true)
* - Clicking requests camera/mic access
* - After setup: Encoder and broadcast controls
* - Shows viewer's own video preview
* - CallControls to start/stop streaming back
* - Device selection controls
*
* USER FLOW:
* 1. Viewer watches broadcaster (left panel loads automatically)
* 2. Player joins call in background (joinedCall event fires)
* 3. Setup button becomes enabled
* 4. Viewer clicks setup → Grants camera/mic access
* 5. Encoder appears with broadcast controls
* 6. Viewer starts broadcast → Broadcaster sees viewer
*/
return (
<div className={classNames.wrapperClassName}>
{/* Left Panel: Broadcaster's Stream */}
<div className={classNames.leftPanelClassName}>
<div className={classNames.playerWrapperClassName}>
<Player
source={manifestUrl}
classNames={classNames.playerClassNames}
eventsMap={playerEventsMap}
requestPlayerOptions={requestPlayerOptions}/>
</div>
</div>

{/* Right Panel: Viewer's Setup/Encoder */}
<div className={classNames.rightPanelClassName}>

{ !showEncoder ?
// Before setup: Show setup button
<div className={classNames.setupCallButtonContainerClassName}>
<button
className={classNames.setupCallButtonClassName}
onClick={handleRequestCall}
disabled={!callJoined}>
Setup Call With Broadcaster
</button>
</div> :
// After setup: Show encoder and broadcast controls
<MediaStreamControllerAPIProvider mediaStreamControllerAPI={mediaStreamController}>
<PlayerAPIProvider playerAPI={previewPlayer}>
<PreviewPlayer classNames={classNames.previewPlayerClassNames}/>
<CallControls call={call} broadcastOptions={{streamName: "default"}} type="broadcast-controls-only" />
</PlayerAPIProvider>
</MediaStreamControllerAPIProvider>
}
</div>
</div>

);

Full Component Code

// PrivateViewer.tsx

/**
* PrivateViewer Component
*
* ADVANCED IMPLEMENTATION: Private Viewing
*
* This component enables viewers to join a private viewing session and stream
* themselves back to the broadcaster. This creates a bidirectional connection where
* both parties can see each other, enabling interactive 1:1 video sessions.
*
* KEY CONCEPTS:
* 1. VIEWER-SIDE BIDIRECTIONAL: Viewer watches broadcaster AND streams back
* 2. AUTOMATIC CALL JOINING: Uses Player's joinedCall event to get call instance
* 3. SETUP FLOW: Watch first → Click setup → Stream back to broadcaster
* 4. EVENT HANDLING: React to being kicked or call ending
*
* USE CASES:
* - 1:1 consultations (patient in doctor-patient, student in tutor-student)
* - Interactive Q&A (viewer asking questions face-to-face)
* - Live customer support (customer showing their issue)
* - Interview or audition (interviewee streaming to interviewer)
*
* ARCHITECTURE:
* - Left panel: Broadcaster's manifest player (passive viewing)
* - Right panel: Setup button → Viewer's preview player (active streaming back)
*
* PREREQUISITES:
* Assumes familiarity with:
* - Player setup and manifest playback
* - Preview player configuration and device controls
* - Call event handling
* - Authentication and WebRTC calls
*/

import React, { useState, useEffect, useCallback, useMemo, memo } from "react";
import { types, hooks, context, requestPlayer, mediaController } from "@video/video-client-react";
/**
* REUSABLE COMPONENTS
*
* - Encoder: Viewer's video preview with device controls
* - Player: Displays broadcaster's manifest stream
* - CallControls: Manages broadcast state (start/stop streaming back)
*
*/
import PreviewPlayer from "../components/PreviewPlayer";
import Player from "../components/Player";
import CallControls from "../components/CallControls";
const { useAuthClient } = hooks;
const { MediaStreamControllerAPIProvider, PlayerAPIProvider} = context;
/**
* STYLING CONFIGURATION
*
*/
const classNames = {
wrapperClassName: "flex flex-row gap-4",
leftPanelClassName: "w-1/2",
rightPanelClassName: "w-1/2",
playerWrapperClassName: "w-full bg-gray-200",
setupCallButtonContainerClassName: "flex flex-col",
setupCallButtonClassName: "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-full cursor-pointer",
callRequestedClassName: "w-full ",
viewerPlayerClassName: "w-full ",
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",
viewerKickedClassName: "w-full h-full",
playerClassNames: {
videoClassName: "object-cover overflow-hidden rounded-xl",
playerContainerClassName: "relative",
},
previewPlayerClassNames: {
wrapperClassName: "flex flex-col",
videoContainerClassName: "relative",
videoClassName: "object-cover overflow-hidden rounded-xl",
controlBarClassName: "flex flex-col",
controlBarItemClassName: "flex flex-col my-2 gap-2",
},
};

/**
* PROPS INTERFACE
*
* Configuration for the viewer to join a direct streaming session
*/
interface DirectStreamingViewerProps {
/**
* Manifest URL of the broadcaster's stream
* This is what the viewer watches (the broadcaster's video)
*/
manifestUrl: string | null;

/**
* Backend API endpoint URL
*/
backendEndpoint: string;

/**
* Authentication token (must have "private-viewer" scope)
*/
token: string;

/**
* Cleanup callback for parent component
* Called when viewer leaves or is kicked
*/
cleanupCb: () => void;
}


function DirectStreamingViewer({ manifestUrl, token, cleanupCb}: DirectStreamingViewerProps): React.ReactElement | null {
/**
* STATE MANAGEMENT
*
* The viewer component manages several pieces of state for the direct streaming flow.
*/

/**
* VIEWER STATUS STATE
* - viewerKicked: Tracks if broadcaster kicked this viewer
* - callJoined: Tracks if viewer has joined the call
* - callClosed: Tracks if call has ended
*/
const [viewerKicked, setViewerKicked] = useState(false);
const [callJoined, setCallJoined] = useState(false);
const [callClosed, setCallClosed] = useState(false);

/**
* MEDIA STATE
* - previewPlayer: Viewer's own video preview (what they see of themselves)
* - mediaStreamController: Controls viewer's camera and microphone
*/
const [previewPlayer, setPreviewPlayer] = useState<types.PlayerAPI | null>(null);
const [mediaStreamController, setMediaStreamController] = useState<types.MediaStreamControllerAPI | null>(null);

/**
* CALL STATE
* - call: The active call instance (obtained from Player's joinedCall event)
*/
const [call, setCall] = useState<types.CallAPI | null>(null);

let initialized = false;
/**
* SETUP CALL HANDLER
*
* This is triggered when viewer clicks "Setup Call With Broadcaster" button.
* It initializes media devices and creates the preview player.
*
* FLOW:
* 1. Initialize media controller (first time only)
* 2. Request media stream controller (camera/mic access)
* 3. Set default video and audio devices
* 4. Create preview player from media stream
* 5. Update state to show encoder
*
* Once this completes, the encoder appears and viewer can start broadcasting back.
*/
const handleRequestCall = useCallback(async () => {
if (!initialized) {
await mediaController.init();
initialized = true;
}
const msc = await mediaController.requestController();
// Set default video device if not set
msc.videoDeviceId = mediaController.videoDevices()[0]?.deviceId ?? null;
// Set default audio device if not set
msc.audioDeviceId = mediaController.audioDevices()[0]?.deviceId ?? null;

const player = await requestPlayer(msc, { autoPlay: true, muted: true });
setMediaStreamController(msc);
setPreviewPlayer(player);

}, [initialized, setMediaStreamController, setPreviewPlayer]);

/**
* AUTHENTICATION
*
* Create auth client with viewer scope to join the call
* Token must have "private-viewer" scope for direct streaming
*/
const authClient = useAuthClient(token);

/**
* PLAYER OPTIONS
*
* Configuration for the broadcaster's manifest player.
* Uses WebRTC driver with authentication to join the call.
*/
const requestPlayerOptions: types.RequestPlayerOptions = useMemo(() => ({
autoPlay: true,
muted: true,
drivers: ["webrtc"],
auth: authClient,
}), [authClient]);

/**
* CLEANUP PAGE HANDLER
*
* Centralized cleanup function called when:
* - Viewer is kicked by broadcaster
* - Call ends
* - Peer (broadcaster) removes viewer
* - Component unmounts
*
* CLEANUP FLOW:
* 1. Dispose media stream controller (release camera/mic)
* 2. Dispose preview player (stop local video)
* 3. Dispose call (disconnect from session)
* 4. Reset state flags
* 5. Call parent's cleanup callback
*
* This ensures proper resource cleanup and prevents memory leaks.
*/
const cleanupPage = useCallback((message: string) => {
if (mediaStreamController != null) {
mediaStreamController.dispose(message);
setMediaStreamController(null);
}
if (previewPlayer != null) {
previewPlayer.dispose(message);
setPreviewPlayer(null);
}
if (call != null) {
call.dispose(message);
setCall(null);
}
setCallJoined(false);
setCallClosed(true);
cleanupCb?.();
}, [ mediaStreamController, previewPlayer, call]);

/**
* PLAYER EVENTS MAP
*
* KEY PATTERN: Automatic Call Joining
*
* Unlike the broadcaster who creates the call, the viewer joins an existing call.
* The Player component fires a "joinedCall" event when it successfully joins,
* providing the call instance.
*
* This pattern allows viewers to:
* 1. Start watching the broadcaster's stream (via manifest)
* 2. Automatically join the call in the background
* 3. Get the call instance via this event
* 4. Then setup their own stream to broadcast back
*
* This is the core difference between viewer and broadcaster flows.
*/
const playerEventsMap = useMemo(() => ({
joinedCall: (ev: types.PlayerCompatEvents["joinedCall"]) => {
setCallJoined(true);
setCall(ev.call);
},
}), []);



/**
* CALL EVENT HANDLING
*
* Viewers need to respond to various call events that can occur during
* a direct streaming session. These events are fired by the broadcaster
* or when the call ends.
*
* EVENTS HANDLED:
* 1. callEnded: Natural end of the call by broadcaster
* 2. viewerKicked: Broadcaster explicitly kicked this viewer
* 3. peerRemoved: Broadcaster disconnected from the call
* 4. callClosed: Call was closed by the system
*
* All events trigger cleanup to release resources and update UI state.
* The viewerKicked event additionally sets a flag to show a kicked message.
*
* IMPORTANT: These events are specific to the viewer experience.
* The broadcaster has different event handling for managing viewers.
*/
useEffect(() => {
if (call == null) return;

const handleCallEnded = () => {
cleanupPage("Disposed by callEnded event");
};

const handleViewerKicked = () => {
cleanupPage("Disposed by viewerKicked event");
setViewerKicked(true);
};

const handlePeerRemoved = (ev: types.CallEvents["peerRemoved"]) => {
cleanupPage("Disposed by peerRemoved event");
};

const handleCallClosed = (ev: types.CallEvents["callClosed"]) => {
cleanupPage("Disposed by callClosed event");
};

// Attach event listeners
call.on("callClosed", handleCallClosed);
call.on("peerRemoved", handlePeerRemoved);
call.on("callEnded", handleCallEnded);
call.on("viewerKicked", handleViewerKicked);

// Cleanup: Remove event listeners when call changes or component unmounts
return () => {
call?.off("viewerKicked", handleViewerKicked);
call?.off("peerRemoved", handlePeerRemoved);
call?.off("callClosed", handleCallClosed);
call?.off("callEnded", handleCallEnded);
};

}, [cleanupPage, call]);

/**
* CONDITIONAL RENDERING FLAG
*
* Determines whether to show the encoder (viewer streaming back).
* All conditions must be true:
* - mediaStreamController: User granted camera/mic access
* - previewPlayer: Preview is ready
* - call: Successfully joined the call
* - manifestUrl: Broadcaster's stream is available
*/
const showEncoder = mediaStreamController != null && previewPlayer != null && call != null && manifestUrl != null;

/**
* CLEANUP EFFECTS
*
* Proper cleanup is critical for direct streaming viewers to avoid:
* - Memory leaks from undisposed players
* - Lingering WebRTC connections
* - Camera/microphone not being released
* - Event listener leaks
*
* We dispose in separate effects to handle each resource independently.
* Each effect cleans up when its dependency changes or on component unmount.
*/

// Cleanup media stream controller
useEffect(() => {
return () => {
if (mediaStreamController != null) {
mediaStreamController.dispose("Disposed by useEffect return in <DirectStreamingViewer/>");
setMediaStreamController(null);
}
};
}, [mediaStreamController]);

// Cleanup preview player
useEffect(() => {
return () => {
if (previewPlayer != null) {
previewPlayer.dispose("Disposed by useEffect return in <DirectStreamingViewer/>");
setPreviewPlayer(null);
}
};
}, [ previewPlayer]);

// Cleanup call
useEffect(() => {
return () => {
if (call != null) {
call.dispose("Disposed by useEffect return in <DirectStreamingViewer/>");
setCall(null);
setCallJoined(false);
setCallClosed(true);
}
};
}, [call]);

// Cleanup callback on unmount
useEffect(() => {
return () => {
if (cleanupCb != null) {
cleanupCb();
}
};
}, []);


/**
* RENDER: CONDITIONAL UI STATES
*
* The viewer interface has three conditional states based on the session status.
*/

/**
* STATE 1: LOADING OR CALL CLOSED
*
* Show empty loading screen when:
* - manifestUrl is not available (can't watch broadcaster)
* - Call has been closed
*
* This is the initial state and the final state after cleanup.
*/
if (manifestUrl == null || callClosed) {
return <div className={classNames.loadingScreenClassName} />;
}

/**
* STATE 2: VIEWER KICKED
*
* Show kicked message when broadcaster has explicitly removed this viewer.
* This provides clear feedback to the user about why they were disconnected.
*/
if (viewerKicked) {
return <div className={classNames.viewerKickedClassName}>Viewer was kicked from the call by the broadcaster</div>;
}

/**
* STATE 3: ACTIVE VIEWING SESSION
*
* Split-panel layout for direct streaming:
*
* LEFT PANEL: Broadcaster's Stream
* - Player component showing broadcaster's manifest
* - Automatically joins call via joinedCall event
* - Provides call instance for streaming back
*
* RIGHT PANEL: Viewer's Interaction (Conditional)
* - Before setup: "Setup Call With Broadcaster" button
* - Disabled until call is joined (callJoined = true)
* - Clicking requests camera/mic access
* - After setup: Encoder and broadcast controls
* - Shows viewer's own video preview
* - CallControls to start/stop streaming back
* - Device selection controls
*
* USER FLOW:
* 1. Viewer watches broadcaster (left panel loads automatically)
* 2. Player joins call in background (joinedCall event fires)
* 3. Setup button becomes enabled
* 4. Viewer clicks setup → Grants camera/mic access
* 5. Encoder appears with broadcast controls
* 6. Viewer starts broadcast → Broadcaster sees viewer
*/
return (
<div className={classNames.wrapperClassName}>
{/* Left Panel: Broadcaster's Stream */}
<div className={classNames.leftPanelClassName}>
<div className={classNames.playerWrapperClassName}>
<Player
source={manifestUrl}
classNames={classNames.playerClassNames}
eventsMap={playerEventsMap}
requestPlayerOptions={requestPlayerOptions}/>
</div>
</div>

{/* Right Panel: Viewer's Setup/Encoder */}
<div className={classNames.rightPanelClassName}>

{ !showEncoder ?
// Before setup: Show setup button
<div className={classNames.setupCallButtonContainerClassName}>
<button
className={classNames.setupCallButtonClassName}
onClick={handleRequestCall}
disabled={!callJoined}>
Setup Call With Broadcaster
</button>
</div> :
// After setup: Show encoder and broadcast controls
<MediaStreamControllerAPIProvider mediaStreamControllerAPI={mediaStreamController}>
<PlayerAPIProvider playerAPI={previewPlayer}>
<PreviewPlayer classNames={classNames.previewPlayerClassNames}/>
<CallControls call={call} broadcastOptions={{streamName: "default"}} type="broadcast-controls-only" />
</PlayerAPIProvider>
</MediaStreamControllerAPIProvider>
}
</div>
</div>

);
}
export default memo(DirectStreamingViewer);

Full Code

Private Viewer Component

// PrivateViewer.tsx

/**
* PrivateViewer Component
*
* ADVANCED IMPLEMENTATION: Private Viewing
*
* This component enables viewers to join a private viewing session and stream
* themselves back to the broadcaster. This creates a bidirectional connection where
* both parties can see each other, enabling interactive 1:1 video sessions.
*
* KEY CONCEPTS:
* 1. VIEWER-SIDE BIDIRECTIONAL: Viewer watches broadcaster AND streams back
* 2. AUTOMATIC CALL JOINING: Uses Player's joinedCall event to get call instance
* 3. SETUP FLOW: Watch first → Click setup → Stream back to broadcaster
* 4. EVENT HANDLING: React to being kicked or call ending
*
* USE CASES:
* - 1:1 consultations (patient in doctor-patient, student in tutor-student)
* - Interactive Q&A (viewer asking questions face-to-face)
* - Live customer support (customer showing their issue)
* - Interview or audition (interviewee streaming to interviewer)
*
* ARCHITECTURE:
* - Left panel: Broadcaster's manifest player (passive viewing)
* - Right panel: Setup button → Viewer's preview player (active streaming back)
*
* PREREQUISITES:
* Assumes familiarity with:
* - Player setup and manifest playback
* - Preview player configuration and device controls
* - Call event handling
* - Authentication and WebRTC calls
*/

import React, { useState, useEffect, useCallback, useMemo, memo } from "react";
import { types, hooks, context, requestPlayer, mediaController } from "@video/video-client-react";
/**
* REUSABLE COMPONENTS
*
* - Encoder: Viewer's video preview with device controls
* - Player: Displays broadcaster's manifest stream
* - CallControls: Manages broadcast state (start/stop streaming back)
*
*/
import PreviewPlayer from "../components/PreviewPlayer";
import Player from "../components/Player";
import CallControls from "../components/CallControls";
const { useAuthClient } = hooks;
const { MediaStreamControllerAPIProvider, PlayerAPIProvider} = context;
/**
* STYLING CONFIGURATION
*
*/
const classNames = {
wrapperClassName: "flex flex-row gap-4",
leftPanelClassName: "w-1/2",
rightPanelClassName: "w-1/2",
playerWrapperClassName: "w-full bg-gray-200",
setupCallButtonContainerClassName: "flex flex-col",
setupCallButtonClassName: "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-full cursor-pointer",
callRequestedClassName: "w-full ",
viewerPlayerClassName: "w-full ",
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",
viewerKickedClassName: "w-full h-full",
playerClassNames: {
videoClassName: "object-cover overflow-hidden rounded-xl",
playerContainerClassName: "relative",
},
previewPlayerClassNames: {
wrapperClassName: "flex flex-col",
videoContainerClassName: "relative",
videoClassName: "object-cover overflow-hidden rounded-xl",
controlBarClassName: "flex flex-col",
controlBarItemClassName: "flex flex-col my-2 gap-2",
},
};

/**
* PROPS INTERFACE
*
* Configuration for the viewer to join a direct streaming session
*/
interface DirectStreamingViewerProps {
/**
* Manifest URL of the broadcaster's stream
* This is what the viewer watches (the broadcaster's video)
*/
manifestUrl: string | null;

/**
* Backend API endpoint URL
*/
backendEndpoint: string;

/**
* Authentication token (must have "private-viewer" scope)
*/
token: string;

/**
* Cleanup callback for parent component
* Called when viewer leaves or is kicked
*/
cleanupCb: () => void;
}


function DirectStreamingViewer({ manifestUrl, token, cleanupCb}: DirectStreamingViewerProps): React.ReactElement | null {
/**
* STATE MANAGEMENT
*
* The viewer component manages several pieces of state for the direct streaming flow.
*/

/**
* VIEWER STATUS STATE
* - viewerKicked: Tracks if broadcaster kicked this viewer
* - callJoined: Tracks if viewer has joined the call
* - callClosed: Tracks if call has ended
*/
const [viewerKicked, setViewerKicked] = useState(false);
const [callJoined, setCallJoined] = useState(false);
const [callClosed, setCallClosed] = useState(false);

/**
* MEDIA STATE
* - previewPlayer: Viewer's own video preview (what they see of themselves)
* - mediaStreamController: Controls viewer's camera and microphone
*/
const [previewPlayer, setPreviewPlayer] = useState<types.PlayerAPI | null>(null);
const [mediaStreamController, setMediaStreamController] = useState<types.MediaStreamControllerAPI | null>(null);

/**
* CALL STATE
* - call: The active call instance (obtained from Player's joinedCall event)
*/
const [call, setCall] = useState<types.CallAPI | null>(null);

let initialized = false;
/**
* SETUP CALL HANDLER
*
* This is triggered when viewer clicks "Setup Call With Broadcaster" button.
* It initializes media devices and creates the preview player.
*
* FLOW:
* 1. Initialize media controller (first time only)
* 2. Request media stream controller (camera/mic access)
* 3. Set default video and audio devices
* 4. Create preview player from media stream
* 5. Update state to show encoder
*
* Once this completes, the encoder appears and viewer can start broadcasting back.
*/
const handleRequestCall = useCallback(async () => {
if (!initialized) {
await mediaController.init();
initialized = true;
}
const msc = await mediaController.requestController();
// Set default video device if not set
msc.videoDeviceId = mediaController.videoDevices()[0]?.deviceId ?? null;
// Set default audio device if not set
msc.audioDeviceId = mediaController.audioDevices()[0]?.deviceId ?? null;

const player = await requestPlayer(msc, { autoPlay: true, muted: true });
setMediaStreamController(msc);
setPreviewPlayer(player);

}, [initialized, setMediaStreamController, setPreviewPlayer]);

/**
* AUTHENTICATION
*
* Create auth client with viewer scope to join the call
* Token must have "private-viewer" scope for direct streaming
*/
const authClient = useAuthClient(token);

/**
* PLAYER OPTIONS
*
* Configuration for the broadcaster's manifest player.
* Uses WebRTC driver with authentication to join the call.
*/
const requestPlayerOptions: types.RequestPlayerOptions = useMemo(() => ({
autoPlay: true,
muted: true,
drivers: ["webrtc"],
auth: authClient,
}), [authClient]);

/**
* CLEANUP PAGE HANDLER
*
* Centralized cleanup function called when:
* - Viewer is kicked by broadcaster
* - Call ends
* - Peer (broadcaster) removes viewer
* - Component unmounts
*
* CLEANUP FLOW:
* 1. Dispose media stream controller (release camera/mic)
* 2. Dispose preview player (stop local video)
* 3. Dispose call (disconnect from session)
* 4. Reset state flags
* 5. Call parent's cleanup callback
*
* This ensures proper resource cleanup and prevents memory leaks.
*/
const cleanupPage = useCallback((message: string) => {
if (mediaStreamController != null) {
mediaStreamController.dispose(message);
setMediaStreamController(null);
}
if (previewPlayer != null) {
previewPlayer.dispose(message);
setPreviewPlayer(null);
}
if (call != null) {
call.dispose(message);
setCall(null);
}
setCallJoined(false);
setCallClosed(true);
cleanupCb?.();
}, [ mediaStreamController, previewPlayer, call]);

/**
* PLAYER EVENTS MAP
*
* KEY PATTERN: Automatic Call Joining
*
* Unlike the broadcaster who creates the call, the viewer joins an existing call.
* The Player component fires a "joinedCall" event when it successfully joins,
* providing the call instance.
*
* This pattern allows viewers to:
* 1. Start watching the broadcaster's stream (via manifest)
* 2. Automatically join the call in the background
* 3. Get the call instance via this event
* 4. Then setup their own stream to broadcast back
*
* This is the core difference between viewer and broadcaster flows.
*/
const playerEventsMap = useMemo(() => ({
joinedCall: (ev: types.PlayerCompatEvents["joinedCall"]) => {
setCallJoined(true);
setCall(ev.call);
},
}), []);



/**
* CALL EVENT HANDLING
*
* Viewers need to respond to various call events that can occur during
* a direct streaming session. These events are fired by the broadcaster
* or when the call ends.
*
* EVENTS HANDLED:
* 1. callEnded: Natural end of the call by broadcaster
* 2. viewerKicked: Broadcaster explicitly kicked this viewer
* 3. peerRemoved: Broadcaster disconnected from the call
* 4. callClosed: Call was closed by the system
*
* All events trigger cleanup to release resources and update UI state.
* The viewerKicked event additionally sets a flag to show a kicked message.
*
* IMPORTANT: These events are specific to the viewer experience.
* The broadcaster has different event handling for managing viewers.
*/
useEffect(() => {
if (call == null) return;

const handleCallEnded = () => {
cleanupPage("Disposed by callEnded event");
};

const handleViewerKicked = () => {
cleanupPage("Disposed by viewerKicked event");
setViewerKicked(true);
};

const handlePeerRemoved = (ev: types.CallEvents["peerRemoved"]) => {
cleanupPage("Disposed by peerRemoved event");
};

const handleCallClosed = (ev: types.CallEvents["callClosed"]) => {
cleanupPage("Disposed by callClosed event");
};

// Attach event listeners
call.on("callClosed", handleCallClosed);
call.on("peerRemoved", handlePeerRemoved);
call.on("callEnded", handleCallEnded);
call.on("viewerKicked", handleViewerKicked);

// Cleanup: Remove event listeners when call changes or component unmounts
return () => {
call?.off("viewerKicked", handleViewerKicked);
call?.off("peerRemoved", handlePeerRemoved);
call?.off("callClosed", handleCallClosed);
call?.off("callEnded", handleCallEnded);
};

}, [cleanupPage, call]);

/**
* CONDITIONAL RENDERING FLAG
*
* Determines whether to show the encoder (viewer streaming back).
* All conditions must be true:
* - mediaStreamController: User granted camera/mic access
* - previewPlayer: Preview is ready
* - call: Successfully joined the call
* - manifestUrl: Broadcaster's stream is available
*/
const showEncoder = mediaStreamController != null && previewPlayer != null && call != null && manifestUrl != null;

/**
* CLEANUP EFFECTS
*
* Proper cleanup is critical for direct streaming viewers to avoid:
* - Memory leaks from undisposed players
* - Lingering WebRTC connections
* - Camera/microphone not being released
* - Event listener leaks
*
* We dispose in separate effects to handle each resource independently.
* Each effect cleans up when its dependency changes or on component unmount.
*/

// Cleanup media stream controller
useEffect(() => {
return () => {
if (mediaStreamController != null) {
mediaStreamController.dispose("Disposed by useEffect return in <DirectStreamingViewer/>");
setMediaStreamController(null);
}
};
}, [mediaStreamController]);

// Cleanup preview player
useEffect(() => {
return () => {
if (previewPlayer != null) {
previewPlayer.dispose("Disposed by useEffect return in <DirectStreamingViewer/>");
setPreviewPlayer(null);
}
};
}, [ previewPlayer]);

// Cleanup call
useEffect(() => {
return () => {
if (call != null) {
call.dispose("Disposed by useEffect return in <DirectStreamingViewer/>");
setCall(null);
setCallJoined(false);
setCallClosed(true);
}
};
}, [call]);

// Cleanup callback on unmount
useEffect(() => {
return () => {
if (cleanupCb != null) {
cleanupCb();
}
};
}, []);


/**
* RENDER: CONDITIONAL UI STATES
*
* The viewer interface has three conditional states based on the session status.
*/

/**
* STATE 1: LOADING OR CALL CLOSED
*
* Show empty loading screen when:
* - manifestUrl is not available (can't watch broadcaster)
* - Call has been closed
*
* This is the initial state and the final state after cleanup.
*/
if (manifestUrl == null || callClosed) {
return <div className={classNames.loadingScreenClassName} />;
}

/**
* STATE 2: VIEWER KICKED
*
* Show kicked message when broadcaster has explicitly removed this viewer.
* This provides clear feedback to the user about why they were disconnected.
*/
if (viewerKicked) {
return <div className={classNames.viewerKickedClassName}>Viewer was kicked from the call by the broadcaster</div>;
}

/**
* STATE 3: ACTIVE VIEWING SESSION
*
* Split-panel layout for direct streaming:
*
* LEFT PANEL: Broadcaster's Stream
* - Player component showing broadcaster's manifest
* - Automatically joins call via joinedCall event
* - Provides call instance for streaming back
*
* RIGHT PANEL: Viewer's Interaction (Conditional)
* - Before setup: "Setup Call With Broadcaster" button
* - Disabled until call is joined (callJoined = true)
* - Clicking requests camera/mic access
* - After setup: Encoder and broadcast controls
* - Shows viewer's own video preview
* - CallControls to start/stop streaming back
* - Device selection controls
*
* USER FLOW:
* 1. Viewer watches broadcaster (left panel loads automatically)
* 2. Player joins call in background (joinedCall event fires)
* 3. Setup button becomes enabled
* 4. Viewer clicks setup → Grants camera/mic access
* 5. Encoder appears with broadcast controls
* 6. Viewer starts broadcast → Broadcaster sees viewer
*/
return (
<div className={classNames.wrapperClassName}>
{/* Left Panel: Broadcaster's Stream */}
<div className={classNames.leftPanelClassName}>
<div className={classNames.playerWrapperClassName}>
<Player
source={manifestUrl}
classNames={classNames.playerClassNames}
eventsMap={playerEventsMap}
requestPlayerOptions={requestPlayerOptions}/>
</div>
</div>

{/* Right Panel: Viewer's Setup/Encoder */}
<div className={classNames.rightPanelClassName}>

{ !showEncoder ?
// Before setup: Show setup button
<div className={classNames.setupCallButtonContainerClassName}>
<button
className={classNames.setupCallButtonClassName}
onClick={handleRequestCall}
disabled={!callJoined}>
Setup Call With Broadcaster
</button>
</div> :
// After setup: Show encoder and broadcast controls
<MediaStreamControllerAPIProvider mediaStreamControllerAPI={mediaStreamController}>
<PlayerAPIProvider playerAPI={previewPlayer}>
<PreviewPlayer classNames={classNames.previewPlayerClassNames}/>
<CallControls call={call} broadcastOptions={{streamName: "default"}} type="broadcast-controls-only" />
</PlayerAPIProvider>
</MediaStreamControllerAPIProvider>
}
</div>
</div>

);
}
export default memo(DirectStreamingViewer);

Supporting Components

The following components are used by the PrivateViewer component and are documented in View a Stream and 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);
// Player.tsx

/**
* Player Component
*
* OVERVIEW:
* A reusable video player component that handles manifest loading, player
* initialization, and provides context for control components. This component
* abstracts away the complexity of requesting a player, managing its lifecycle,
* and providing it to child components.
*
* WHAT IT DOES:
* - Loads and plays video streams from manifest URLs (HLS, FLV, DASH)
* - Initializes the player using the useRequestPlayer hook
* - Provides PlayerAPIProvider context to all children
* - Renders the video element with customizable styling
* - Manages player lifecycle (creation and disposal)
*
* KEY FEATURES:
* - Fully self-contained: Handles all player setup automatically
* - Customizable styling via classNames prop
* - Supports children for adding custom controls
* - Works with any manifest format supported by the video client
* - Automatic error handling and loading states
*
* REQUIREMENTS:
* - A valid manifest URL (HLS .m3u8, FLV .flv, or DASH .mpd)
* - No additional context providers needed (self-contained)
*
* @example Basic usage:
* ```tsx
* <Player source="https://example.com/stream.m3u8">
* <TogglePlayButton />
* <ToggleMuteButton />
* </Player>
* ```
*
* @example With custom styling:
* ```tsx
* <Player
* source={manifestUrl}
* classNames={{
* videoClassName: "w-full h-auto",
* playerContainerClassName: "relative"
* }}
* >
* <YourCustomControls />
* </Player>
* ```
*/


import React, {useRef} from "react";
/**
* IMPORT VIDEO CLIENT COMPONENTS AND HOOKS
*
* - components: Pre-built UI components (Video element wrapper)
* - context: React Context providers and hooks (PlayerAPIProvider)
* - hooks: Custom hooks for video functionality (useRequestPlayer)
*/
import { components, context, hooks } from "@video/video-client-react";

/**
* EXTRACT REQUIRED EXPORTS
*
* - PlayerAPIProvider: Context provider that makes player instance available to children
* - Video: Video element wrapper with built-in player integration
* - useRequestPlayer: Hook that requests and initializes a player from a manifest URL
*/
const { PlayerAPIProvider, usePeerAPI } = context;
const { Video } = components;
const { useRequestPlayer } = hooks;

/**
* STYLING INTERFACE
*
* Defines the CSS classes that can be customized for different parts
* of the player component. All fields are optional.
*/
export interface PlayerClassNames {
playerClassName: string;
videoClassName: string;
displayNameClassName: string;
playerContainerClassName: string;
}

/**
* DEFAULT STYLING
*
* Default styling using Tailwind CSS classes.
*/
const defaultClassNames: PlayerClassNames = {
playerClassName: "bg-black-200",
videoClassName: "h-56 w-56 object-cover overflow-hidden rounded-xl",
displayNameClassName: "text-sm text-white absolute bottom-1 left-1 z-[200] bg-black/50 px-2 py-1 rounded-md",
playerContainerClassName: "relative h-56 w-56",
};

/**
* PROPS INTERFACE
*
* Extends UseRequestPlayerOptions to inherit all player configuration options
* like source, requestPlayerOptions, and eventsMap.
*/
interface PlayerProps extends hooks.UseRequestPlayerOptions {
/** Child components (typically player controls) */
children?: React.ReactNode;
/** Optional custom class names (partial override of defaults) */
classNames?: Partial<PlayerClassNames>;
}


function Player({source, children, classNames, requestPlayerOptions, eventsMap}: PlayerProps): JSX.Element | null {
/**
* MERGE CUSTOM AND DEFAULT CLASS NAMES
*
* Combines default styling with any custom overrides provided via props.
*/
const mergedClassNames = { ...defaultClassNames, ...classNames };

/**
* VIDEO ELEMENT REF
*
* Creates a reference to the HTML video element.
* While not strictly required for basic playback (the Video component handles
* this internally), having a ref available can be useful for:
* - Direct DOM manipulation if needed
* - Integration with third-party libraries
* - Advanced video element access
*/
const videoElement = useRef<HTMLVideoElement>(null);

/**
* REQUEST AND INITIALIZE PLAYER
*
* The useRequestPlayer hook is the core of this component. It:
* 1. Takes the manifest URL (source) and loads it
* 2. Determines the appropriate player technology (HLS.js, FLV.js, native)
* 3. Creates a player instance configured for that technology
* 4. Handles errors and loading states automatically
* 5. Returns the player instance (or null if still loading/failed)
*
* The hook also handles cleanup when the component unmounts, ensuring
* the player is properly disposed and resources are freed.
*
* PARAMETERS:
* - source: The manifest URL to load (required)
* - requestPlayerOptions: Optional configuration for the player
* - eventsMap: Optional event listeners to attach to the player
*/
const player = useRequestPlayer({ source, requestPlayerOptions, eventsMap });

/**
* LOADING STATE CHECK
*
* If the player hasn't been created yet (still loading or failed),
* return null to render nothing.
*
* In a production app, you might want to show:
* - A loading spinner while player is initializing
* - An error message if loading failed
* - A placeholder image or poster frame
*/
if (player == null) return null;

/**
* RENDER THE PLAYER UI
*
* STRUCTURE:
* PlayerAPIProvider (makes player available to children)
* └── Outer container (playerClassName)
* └── Video container (playerContainerClassName)
* ├── Video element (videoClassName) - displays the stream
* └── Children - typically control buttons and UI elements
*
* HOW IT WORKS:
* 1. PlayerAPIProvider shares the player instance via React Context
* 2. All child components can access the player using usePlayerAPI()
* 3. The Video component connects to the player and displays the video
* 4. Children (controls) automatically sync with player state
*
* IMPORTANT: The PlayerAPIProvider is crucial - without it, child
* components wouldn't be able to access the player instance.
*/
return (
<PlayerAPIProvider playerAPI={player}>
{/* Outer wrapper for styling */}
<div className={mergedClassNames.playerClassName ?? ""}>
{/* Container for video and overlays */}
<div className={mergedClassNames.playerContainerClassName ?? ""}>
{/* Video element - displays the actual stream */}
<Video id="player-video" ref={videoElement} className={mergedClassNames.videoClassName ?? ""}/>

{/* Child components - typically controls */}
{children}
</div>
</div>
</PlayerAPIProvider>
);
}

/**
* EXPORT THE PLAYER COMPONENT
*
* This component is designed to be highly reusable. It can be used:
* - As-is for basic manifest playback
* - As a wrapper for custom player UIs
* - In galleries showing multiple streams
* - For VOD, live streams, or transcoded content
*/
export default Player;
// 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 };

Key Concepts

Bidirectional Streaming

Bidirectional streaming enables two-way video communication:

  • Viewer watches broadcaster: Via manifest player with WebRTC driver
  • Broadcaster sees viewer: Via viewer's broadcast
  • 1:1 connection: Both parties interact in real-time
  • Single call: All communication happens through one WebRTC call

joinedCall Event

The joinedCall event is crucial for direct streaming:

  • Fired by: Player's WebRTC driver when successfully joining the call
  • Provides: The call instance for broadcasting back
  • Enables: Setup button and event listener attachment
  • Different from: Creating a call directly (broadcaster pattern)

Call vs Broadcast

Understanding the distinction:

  • Call: The connection to the Native Frame backend (joined via player)
  • Broadcast: Sending media through the call (viewer's camera/mic to broadcaster)
  • Viewer flow: Join call (automatic) → Set up encoder → Start broadcast

Event Handling

The viewer responds to broadcaster actions:

viewerKicked:

  • Broadcaster explicitly removed this viewer
  • Shows alert to user
  • Triggers cleanup

callEnded:

  • Broadcaster ended the call
  • Triggers cleanup for all participants

Authentication Scope

Direct Streaming Viewer:

  • Requires "private-viewer" scope
  • Can join call and broadcast back
  • Different from "viewer" scope (cannot broadcast)

Basic Viewer:

  • Uses "viewer" or "private-viewer" scope
  • Can join call but typically doesn't broadcast

Broadcaster:

  • Uses "broadcaster" scope
  • Creates calls and manages viewers

Direct Streaming vs Other Patterns

FeatureDirect Streaming ViewerBasic WebRTC ViewerManifest Player
ConnectionManifest player with WebRTC driverExplicit callId joinTranscoded streams
Call JoiningAutomatic via joinedCall eventExplicitNone
Broadcast BackYes (encoder setup)NoNo
CommunicationBidirectionalView onlyView only
Use Case1:1 interactive sessionMulti-party viewingHigher latency, no interaction

Next Steps

To learn more about advanced streaming concepts: