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:
-
Call Joining vs Creation
- Viewer: Joins existing call via Player's
joinedCallevent - Broadcaster: Creates the call
- Viewer: Joins existing call via Player's
-
Event Handling
- Viewer: Responds to
viewerKicked,callEnded,peerRemoved - Broadcaster: Handles
streamAdded,streamRemoved(manages viewers)
- Viewer: Responds to
-
Setup Flow
- Viewer: Watch first via manifestUrl → Setup button → Stream back
- Broadcaster: Create call → Accept viewer requests
-
Authentication Scope
- Viewer: "private-viewer" scope
- Broadcaster: "broadcaster" scope
- React
- Vanilla JavaScript
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:
- The
usePreviewPlayeranduseAuthClienthooks. - The
useCallControlscustom hook. - The
PreviewPlayercomponent for device controls. - The
Playercomponent for stream playback.
Private Viewer Component
The Private Viewer component demonstrates:
- Automatic Call Joining - Uses Player's
joinedCallevent to obtain call instance (doesn't create call) - Setup Flow - Watch broadcaster first → Click setup → Stream back
- Event Handling - React to
viewerKicked,callEnded,peerRemovedevents - Bidirectional Streaming - View broadcaster AND stream back simultaneously
The component uses:
- hooks:
useAuthClientfor authentication and media setup - context:
MediaStreamControllerAPIProvider,PlayerAPIProviderfor 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
joinedCallevent (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:
- Player connects to broadcaster's manifest → WebRTC driver auto-joins call
joinedCallevent fires → Provides call instance- We set
callJoinedto true → Enables "Setup Call With Broadcaster" button - Viewer clicks setup → Grants camera/mic access
- 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:
viewerKicked: Broadcaster explicitly removed this viewer → Show kicked messagecallEnded: Call ended by broadcaster → CleanuppeerRemoved: Broadcaster disconnected → CleanupcallClosed: 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
joinedCallevent 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
- Before setup: "Setup Call With Broadcaster" button (disabled until
User Flow:
- Viewer watches broadcaster (left panel)
- Player joins call in background →
joinedCallevent fires - Setup button becomes enabled
- Viewer clicks setup → Grants camera/mic access
- Encoder appears with broadcast controls
- 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 };
This guide demonstrates an ADVANCED implementation of bidirectional streaming using vanilla JavaScript with the @video/video-client-core library.
Prerequisites
This is an advanced implementation that assumes familiarity with:
- Basic preview player setup: See Broadcasting a Livestream
- *WebRTC player concepts: See View a Call Stream
Before you begin, you'll need:
- Authentication Token: A JWT token with "private-viewer" scope (from Native Frame)
- Manifest URL: The broadcaster's stream URL
- Backend Endpoint: Your Native Frame backend URL
Overview
The private viewer application consists of four main files:
- private-viewer.html - HTML structure, styles, and script imports
- auth.js - Authentication configuration and client setup
- preview-player-utils.js - Utility functions for setting up the viewer's encoder
- private-viewer.js - Main application logic for bidirectional streaming
HTML
The page is organized into two main sections:
Viewer Section (Top):
startEncoderBtn: Hidden until joinedCall event (enables viewer to set up encoder)encoderContainer: Will be populated with viewer's video preview and device controlsbroadcastBtn: Hidden until encoder is set up (starts/stops viewer's broadcast)
Broadcaster Section (Bottom):
broadcasterVideo: Displays the broadcaster's manifest stream- No controls needed (passive viewing)
User Flow:
- Page loads → Broadcaster's manifest player starts
- Player joins call → "Start Viewer Encoder" button appears
- Viewer clicks button → Grants camera/mic access, encoder appears
- Viewer clicks "Start Broadcasting" → Broadcaster sees viewer
- Both parties can now see each other (bidirectional streaming)
<!--
Native Frame Direct Streaming Viewer - Vanilla JavaScript Implementation
This HTML file demonstrates an advanced implementation of bidirectional streaming
using the @video/video-client-core library with vanilla JavaScript.
What is Direct Streaming?
Direct Streaming enables bidirectional streaming where a viewer joins a broadcast
and streams themselves back to the broadcaster. This creates a 1:1 interactive
connection where both parties can see and interact with each other.
Key Use Cases:
- 1:1 consultations (patient-doctor, student-tutor)
- Interactive Q&A sessions
- Live customer support with video
- Interview or audition sessions
The direct streaming viewer application:
- Watches the broadcaster via manifest player
- Automatically joins the call when the player connects
- Sets up encoder when viewer clicks "Start Viewer Encoder"
- Streams back to broadcaster via broadcast
- Handles call events (kicked, ended, etc.)
Prerequisites:
This is an ADVANCED implementation that assumes familiarity with:
- Basic encoder setup (see /broadcasting-a-livestream/set-up-a-livestream-video)
- Manifest player concepts (see /consuming-a-livestream/play-a-stream-using-a-manifest)
- WebRTC call concepts (see /consuming-a-livestream/view-a-stream)
Required Setup:
1. Obtain authentication token with "private-viewer" scope
2. Get the manifest URL from the broadcaster
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 viewer's encoder
3. player-utils.js - Provides helper functions for managing playback (unused in this example but may be needed)
4. direct-streaming-viewer.js - Main application logic for direct streaming
This implementation combines concepts from both the encoder and player examples.
-->
<script type="module" src="/js/auth.js"></script>
<script type="module" src="/js/encoder-utils.js"></script>
<script type="module" src="/js/player-utils.js"></script>
<script type="module" src="/js/direct-streaming-viewer.js"></script>
<!--
Styling for Bidirectional Streaming Layout
The page uses a vertical layout with:
- Viewer section (top): Shows viewer's own encoder and controls
- Broadcaster section (bottom): Shows broadcaster's manifest 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. Viewer Section (Top):
- startEncoderBtn: Hidden until joinedCall event (enables viewer to set up encoder)
- encoderContainer: Will be populated with viewer's video preview and device controls
- broadcastBtn: Hidden until encoder is set up (starts/stops viewer's broadcast)
2. Broadcaster Section (Bottom):
- broadcasterVideo: Displays the broadcaster's manifest stream
- No controls needed (passive viewing)
User Flow:
1. Page loads → Broadcaster's manifest player starts
2. Player joins call → "Start Viewer Encoder" button appears
3. Viewer clicks button → Grants camera/mic access, encoder appears
4. Viewer clicks "Start Broadcasting" → Broadcaster sees viewer
5. Both parties can now see each other (bidirectional streaming)
-->
<div class="container">
<div class="container">
<!-- Viewer Section - Where viewer's own video will appear -->
<div>
<h3>Viewer</h3>
<!-- Button to initialize viewer's encoder (hidden until call is joined) -->
<button id="startEncoderBtn" style="display: none;">Start Viewer Encoder</button>
<!-- Viewer's video preview and device controls will be injected here -->
<div id="encoderContainer"></div>
<!-- Button to start/stop viewer's broadcast (hidden until encoder is set up) -->
<button id="broadcastBtn" style="display: none;">Start Broadcasting</button>
</div>
<!-- Broadcaster Section - Where broadcaster's video appears -->
<div>
<h3>Broadcaster</h3>
<div id="videoContainer">
<!-- Broadcaster's manifest stream will be displayed here -->
<video id="broadcasterVideo" style="background: black; height: 293px; width: 100%;"></video>
</div>
</div>
</div>
</div>
</body>
</html>
Main Application Logic (private-viewer.js)
The main application file handles the bidirectional streaming setup.
/**
* Native Frame Direct Streaming Viewer - Main Application
*
* This is an ADVANCED implementation demonstrating bidirectional streaming where
* a viewer joins a broadcast and streams themselves back to the broadcaster.
* 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)
* - Manifest player concepts (see /consuming-a-livestream/play-a-stream-using-a-manifest)
* - WebRTC call concepts (see /consuming-a-livestream/view-a-stream)
*
* Flow:
* 1. Page loads → Manifest player loads broadcaster's stream
* 2. Player joins call → joinedCall event fires, setup button appears
* 3. Viewer clicks setup → requestEncoder() grants camera/mic access
* 4. Viewer clicks broadcast → Streams back to broadcaster
* 5. Both parties can see each other (bidirectional streaming)
* 6. Call events (kicked, ended) → Cleanup and disconnect
*
* Key Differences from Basic Viewer:
* - Uses manifest player with WebRTC driver to automatically join call
* - Viewer sets up encoder to stream back
* - Handles bidirectional streaming in a single connection
* - Responds to viewerKicked event (broadcaster can remove viewer)
*/
import { requestEncoder } from './encoder-utils.js';
import { setAuthClient, viewerToken } from './auth.js';
import { requestPlayer } from 'vdc-cdn';
/**
* Manifest URL Configuration
*
* The manifest URL points to the broadcaster's stream. In a production application,
* you would typically:
* - Receive the manifest URL from your backend API
* - Get it from a URL parameter (e.g., /watch?manifestUrl=...)
* - Fetch it from your video management system
* - Have the broadcaster share it with the viewer
*
* Example ways to obtain manifestUrl:
* const manifestUrl = new URLSearchParams(window.location.search).get('manifestUrl');
* const manifestUrl = await fetch('/api/broadcast-manifest').then(r => r.json()).then(d => d.url);
*/
const manifestUrl = "your-manifest-url-here"; // Replace with actual manifest URL
/**
* Application State Variables
*
* These module-level variables maintain the state of the direct streaming viewer:
*
* - authClient: Authenticated client for API requests (with "private-viewer" scope)
* - mediaStreamController: Manages viewer's camera and microphone
* - previewPlayer: Displays viewer's own video preview
* - call: The WebRTC call instance (obtained from joinedCall event, not created by viewer)
* - viewer: Reserved for future use
* - broadcast: Viewer's broadcast instance (streams viewer back to broadcaster)
* - broadcasterPlayer: Player displaying broadcaster's manifest stream
*
* All are initialized to null and populated during the application lifecycle.
*/
let authClient = null;
let mediaStreamController = null;
let previewPlayer = null;
let call = null;
let viewer = null;
let broadcast = null;
let broadcasterPlayer = null;
/**
* Initialize the Direct Streaming Viewer Application
*
* This is the main initialization function that sets up bidirectional streaming.
* It runs when the page loads and performs these steps:
*
* 1. Creates an authenticated client using the viewer token (must have "private-viewer" scope)
* 2. Requests a player for the broadcaster's manifest stream
* 3. Configures the player to use WebRTC driver (enables automatic call joining)
* 4. Attaches the player to the video element
* 5. Sets up event handlers for joinedCall, callEnded, and viewerKicked events
* 6. Wires up encoder setup and broadcast controls
*
* Key Pattern: The viewer JOINS an existing call (doesn't create one)
* The player's WebRTC driver automatically joins when connecting to the manifest.
*/
async function init() {
try {
// Create authenticated client for API requests (must have "private-viewer" scope)
authClient = await setAuthClient(viewerToken);
// Request a player for the broadcaster's manifest stream
// drivers: ["webrtc"] - Forces WebRTC driver which automatically joins the call
// auth: authClient - Required for joining the call
// autoPlay: true - Starts playing immediately
broadcasterPlayer = await requestPlayer(manifestUrl, {
drivers: ["webrtc"],
auth: authClient,
autoPlay: true,
});
// Attach the player to the broadcaster video element
broadcasterPlayer?.attachTo(document.getElementById('broadcasterVideo'));
// Handle joinedCall event (KEY PATTERN for direct streaming)
// This event fires when the player's WebRTC driver successfully joins the call
broadcasterPlayer?.on("joinedCall", (ev) => {
// Store the call instance for broadcasting back
call = ev.call;
// Show the encoder setup button now that we're connected to the call
document.getElementById("startEncoderBtn").style.display = "block";
// Handle callEnded event
// Fired when the broadcaster ends the call
ev.call.on("callEnded", () => {
// Hide broadcast button and clean up resources
document.getElementById("broadcastBtn").style.display = "none";
cleanupPage("Disposed by callEnded event");
});
// Handle viewerKicked event (IMPORTANT for direct streaming)
// Fired when the broadcaster explicitly removes this viewer
ev.call.on("viewerKicked", () => {
alert("You have been kicked from the broadcast");
cleanupPage("Disposed by viewerKicked event");
});
/**
* Toggle Viewer's Broadcast
*
* Starts or stops the viewer's broadcast back to the broadcaster.
* This is what enables the bidirectional streaming.
*
* @param {Event} event - Click event from the broadcast button
*/
async function toggleBroadcast(event) {
if (broadcast == null) {
// Start broadcasting viewer's stream back to broadcaster
event.target.disabled = true;
broadcast = await ev.call.broadcast(mediaStreamController, { streamName: "default" });
event.target.textContent = "Stop Broadcast";
event.target.disabled = false;
} else {
// Stop broadcasting
event.target.disabled = true;
broadcast.dispose("broadcast disposed via toggleBroadcast()");
broadcast = null;
event.target.textContent = "Start Broadcast";
event.target.disabled = false;
}
}
// Wire up broadcast toggle button
document.getElementById("broadcastBtn").onclick = toggleBroadcast;
});
/**
* Handle Encoder Initialization
*
* Sets up the viewer's camera and microphone when they click "Start Viewer Encoder".
* This function:
* 1. Requests camera/mic access
* 2. Creates the encoder preview
* 3. Shows broadcast controls
*
* This is called AFTER the call is joined, so the viewer is already watching
* the broadcaster before setting up their own camera.
*/
async function handleInitEncoder() {
// Request encoder (see encoder-utils.js for details)
const [msc, preview] = await requestEncoder();
mediaStreamController = msc;
previewPlayer = preview;
// Hide setup button, show broadcast button
document.getElementById('startEncoderBtn').style.display = "none";
document.getElementById("broadcastBtn").style.display = "block";
}
// Wire up encoder setup button
document.getElementById('startEncoderBtn').onclick = handleInitEncoder;
// Register cleanup handler
document.addEventListener("visibilitychange", dispose);
} catch (error) {
console.error('Error:', error);
alert(`Failed to load manifest: ${error.message}`);
}
}
/**
* Clean Up All Resources (Centralized Cleanup)
*
* Disposes of all resources when the viewer needs to disconnect.
* This is called in multiple scenarios:
* - Viewer is kicked by broadcaster (viewerKicked event)
* - Call ends naturally (callEnded event)
* - Broadcaster disconnects
* - Page is unloaded
*
* Cleanup process:
* 1. Dispose of viewer's media devices (camera/microphone)
* 2. Dispose of viewer's preview player
* 3. Dispose of viewer's broadcast
* 4. Dispose of the call connection
* 5. Dispose of broadcaster's player
* 6. Clear all references
*
* @param {string} reason - Reason for cleanup (for logging/debugging)
*/
function cleanupPage(reason) {
console.log('Cleanup:', reason);
// Dispose of viewer's media devices
mediaStreamController?.dispose();
mediaStreamController = null;
// Dispose of viewer's preview player
previewPlayer?.dispose();
previewPlayer = null;
// Dispose of viewer's broadcast
broadcast?.dispose();
broadcast = null;
// Dispose of the call connection
call?.dispose();
call = null;
// Dispose of broadcaster's player
broadcasterPlayer?.dispose();
broadcasterPlayer = 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 user navigates
* away or closes the tab.
*
* 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() {
window.addEventListener('beforeunload', () => {
cleanupPage('Page unload');
});
}
/**
* Application Entry Point
*
* This runs when the page finishes loading. It calls init() to set up
* the direct streaming viewer.
*/
window.onload = async () => {
await init();
};
Full Code
Private Viewer HTML + JS
<!--
Native Frame Direct Streaming Viewer - Vanilla JavaScript Implementation
This HTML file demonstrates an advanced implementation of bidirectional streaming
using the @video/video-client-core library with vanilla JavaScript.
What is Direct Streaming?
Direct Streaming enables bidirectional streaming where a viewer joins a broadcast
and streams themselves back to the broadcaster. This creates a 1:1 interactive
connection where both parties can see and interact with each other.
Key Use Cases:
- 1:1 consultations (patient-doctor, student-tutor)
- Interactive Q&A sessions
- Live customer support with video
- Interview or audition sessions
The direct streaming viewer application:
- Watches the broadcaster via manifest player
- Automatically joins the call when the player connects
- Sets up encoder when viewer clicks "Start Viewer Encoder"
- Streams back to broadcaster via broadcast
- Handles call events (kicked, ended, etc.)
Prerequisites:
This is an ADVANCED implementation that assumes familiarity with:
- Basic encoder setup (see /broadcasting-a-livestream/set-up-a-livestream-video)
- Manifest player concepts (see /consuming-a-livestream/play-a-stream-using-a-manifest)
- WebRTC call concepts (see /consuming-a-livestream/view-a-stream)
Required Setup:
1. Obtain authentication token with "private-viewer" scope
2. Get the manifest URL from the broadcaster
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 viewer's encoder
3. player-utils.js - Provides helper functions for managing playback (unused in this example but may be needed)
4. direct-streaming-viewer.js - Main application logic for direct streaming
This implementation combines concepts from both the encoder and player examples.
-->
<script type="module" src="/js/auth.js"></script>
<script type="module" src="/js/encoder-utils.js"></script>
<script type="module" src="/js/player-utils.js"></script>
<script type="module" src="/js/direct-streaming-viewer.js"></script>
<!--
Styling for Bidirectional Streaming Layout
The page uses a vertical layout with:
- Viewer section (top): Shows viewer's own encoder and controls
- Broadcaster section (bottom): Shows broadcaster's manifest 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. Viewer Section (Top):
- startEncoderBtn: Hidden until joinedCall event (enables viewer to set up encoder)
- encoderContainer: Will be populated with viewer's video preview and device controls
- broadcastBtn: Hidden until encoder is set up (starts/stops viewer's broadcast)
2. Broadcaster Section (Bottom):
- broadcasterVideo: Displays the broadcaster's manifest stream
- No controls needed (passive viewing)
User Flow:
1. Page loads → Broadcaster's manifest player starts
2. Player joins call → "Start Viewer Encoder" button appears
3. Viewer clicks button → Grants camera/mic access, encoder appears
4. Viewer clicks "Start Broadcasting" → Broadcaster sees viewer
5. Both parties can now see each other (bidirectional streaming)
-->
<div class="container">
<div class="container">
<!-- Viewer Section - Where viewer's own video will appear -->
<div>
<h3>Viewer</h3>
<!-- Button to initialize viewer's encoder (hidden until call is joined) -->
<button id="startEncoderBtn" style="display: none;">Start Viewer Encoder</button>
<!-- Viewer's video preview and device controls will be injected here -->
<div id="encoderContainer"></div>
<!-- Button to start/stop viewer's broadcast (hidden until encoder is set up) -->
<button id="broadcastBtn" style="display: none;">Start Broadcasting</button>
</div>
<!-- Broadcaster Section - Where broadcaster's video appears -->
<div>
<h3>Broadcaster</h3>
<div id="videoContainer">
<!-- Broadcaster's manifest stream will be displayed here -->
<video id="broadcasterVideo" style="background: black; height: 293px; width: 100%;"></video>
</div>
</div>
</div>
</div>
</body>
</html>
/**
* Native Frame Direct Streaming Viewer - Main Application
*
* This is an ADVANCED implementation demonstrating bidirectional streaming where
* a viewer joins a broadcast and streams themselves back to the broadcaster.
* 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)
* - Manifest player concepts (see /consuming-a-livestream/play-a-stream-using-a-manifest)
* - WebRTC call concepts (see /consuming-a-livestream/view-a-stream)
*
* Flow:
* 1. Page loads → Manifest player loads broadcaster's stream
* 2. Player joins call → joinedCall event fires, setup button appears
* 3. Viewer clicks setup → requestEncoder() grants camera/mic access
* 4. Viewer clicks broadcast → Streams back to broadcaster
* 5. Both parties can see each other (bidirectional streaming)
* 6. Call events (kicked, ended) → Cleanup and disconnect
*
* Key Differences from Basic Viewer:
* - Uses manifest player with WebRTC driver to automatically join call
* - Viewer sets up encoder to stream back
* - Handles bidirectional streaming in a single connection
* - Responds to viewerKicked event (broadcaster can remove viewer)
*/
import { requestEncoder } from './encoder-utils.js';
import { setAuthClient, viewerToken } from './auth.js';
import { requestPlayer } from 'vdc-cdn';
/**
* Manifest URL Configuration
*
* The manifest URL points to the broadcaster's stream. In a production application,
* you would typically:
* - Receive the manifest URL from your backend API
* - Get it from a URL parameter (e.g., /watch?manifestUrl=...)
* - Fetch it from your video management system
* - Have the broadcaster share it with the viewer
*
* Example ways to obtain manifestUrl:
* const manifestUrl = new URLSearchParams(window.location.search).get('manifestUrl');
* const manifestUrl = await fetch('/api/broadcast-manifest').then(r => r.json()).then(d => d.url);
*/
const manifestUrl = "your-manifest-url-here"; // Replace with actual manifest URL
/**
* Application State Variables
*
* These module-level variables maintain the state of the direct streaming viewer:
*
* - authClient: Authenticated client for API requests (with "private-viewer" scope)
* - mediaStreamController: Manages viewer's camera and microphone
* - previewPlayer: Displays viewer's own video preview
* - call: The WebRTC call instance (obtained from joinedCall event, not created by viewer)
* - viewer: Reserved for future use
* - broadcast: Viewer's broadcast instance (streams viewer back to broadcaster)
* - broadcasterPlayer: Player displaying broadcaster's manifest stream
*
* All are initialized to null and populated during the application lifecycle.
*/
let authClient = null;
let mediaStreamController = null;
let previewPlayer = null;
let call = null;
let viewer = null;
let broadcast = null;
let broadcasterPlayer = null;
/**
* Initialize the Direct Streaming Viewer Application
*
* This is the main initialization function that sets up bidirectional streaming.
* It runs when the page loads and performs these steps:
*
* 1. Creates an authenticated client using the viewer token (must have "private-viewer" scope)
* 2. Requests a player for the broadcaster's manifest stream
* 3. Configures the player to use WebRTC driver (enables automatic call joining)
* 4. Attaches the player to the video element
* 5. Sets up event handlers for joinedCall, callEnded, and viewerKicked events
* 6. Wires up encoder setup and broadcast controls
*
* Key Pattern: The viewer JOINS an existing call (doesn't create one)
* The player's WebRTC driver automatically joins when connecting to the manifest.
*/
async function init() {
try {
// Create authenticated client for API requests (must have "private-viewer" scope)
authClient = await setAuthClient(viewerToken);
// Request a player for the broadcaster's manifest stream
// drivers: ["webrtc"] - Forces WebRTC driver which automatically joins the call
// auth: authClient - Required for joining the call
// autoPlay: true - Starts playing immediately
broadcasterPlayer = await requestPlayer(manifestUrl, {
drivers: ["webrtc"],
auth: authClient,
autoPlay: true,
});
// Attach the player to the broadcaster video element
broadcasterPlayer?.attachTo(document.getElementById('broadcasterVideo'));
// Handle joinedCall event (KEY PATTERN for direct streaming)
// This event fires when the player's WebRTC driver successfully joins the call
broadcasterPlayer?.on("joinedCall", (ev) => {
// Store the call instance for broadcasting back
call = ev.call;
// Show the encoder setup button now that we're connected to the call
document.getElementById("startEncoderBtn").style.display = "block";
// Handle callEnded event
// Fired when the broadcaster ends the call
ev.call.on("callEnded", () => {
// Hide broadcast button and clean up resources
document.getElementById("broadcastBtn").style.display = "none";
cleanupPage("Disposed by callEnded event");
});
// Handle viewerKicked event (IMPORTANT for direct streaming)
// Fired when the broadcaster explicitly removes this viewer
ev.call.on("viewerKicked", () => {
alert("You have been kicked from the broadcast");
cleanupPage("Disposed by viewerKicked event");
});
/**
* Toggle Viewer's Broadcast
*
* Starts or stops the viewer's broadcast back to the broadcaster.
* This is what enables the bidirectional streaming.
*
* @param {Event} event - Click event from the broadcast button
*/
async function toggleBroadcast(event) {
if (broadcast == null) {
// Start broadcasting viewer's stream back to broadcaster
event.target.disabled = true;
broadcast = await ev.call.broadcast(mediaStreamController, { streamName: "default" });
event.target.textContent = "Stop Broadcast";
event.target.disabled = false;
} else {
// Stop broadcasting
event.target.disabled = true;
broadcast.dispose("broadcast disposed via toggleBroadcast()");
broadcast = null;
event.target.textContent = "Start Broadcast";
event.target.disabled = false;
}
}
// Wire up broadcast toggle button
document.getElementById("broadcastBtn").onclick = toggleBroadcast;
});
/**
* Handle Encoder Initialization
*
* Sets up the viewer's camera and microphone when they click "Start Viewer Encoder".
* This function:
* 1. Requests camera/mic access
* 2. Creates the encoder preview
* 3. Shows broadcast controls
*
* This is called AFTER the call is joined, so the viewer is already watching
* the broadcaster before setting up their own camera.
*/
async function handleInitEncoder() {
// Request encoder (see encoder-utils.js for details)
const [msc, preview] = await requestEncoder();
mediaStreamController = msc;
previewPlayer = preview;
// Hide setup button, show broadcast button
document.getElementById('startEncoderBtn').style.display = "none";
document.getElementById("broadcastBtn").style.display = "block";
}
// Wire up encoder setup button
document.getElementById('startEncoderBtn').onclick = handleInitEncoder;
// Register cleanup handler
document.addEventListener("visibilitychange", dispose);
} catch (error) {
console.error('Error:', error);
alert(`Failed to load manifest: ${error.message}`);
}
}
/**
* Clean Up All Resources (Centralized Cleanup)
*
* Disposes of all resources when the viewer needs to disconnect.
* This is called in multiple scenarios:
* - Viewer is kicked by broadcaster (viewerKicked event)
* - Call ends naturally (callEnded event)
* - Broadcaster disconnects
* - Page is unloaded
*
* Cleanup process:
* 1. Dispose of viewer's media devices (camera/microphone)
* 2. Dispose of viewer's preview player
* 3. Dispose of viewer's broadcast
* 4. Dispose of the call connection
* 5. Dispose of broadcaster's player
* 6. Clear all references
*
* @param {string} reason - Reason for cleanup (for logging/debugging)
*/
function cleanupPage(reason) {
console.log('Cleanup:', reason);
// Dispose of viewer's media devices
mediaStreamController?.dispose();
mediaStreamController = null;
// Dispose of viewer's preview player
previewPlayer?.dispose();
previewPlayer = null;
// Dispose of viewer's broadcast
broadcast?.dispose();
broadcast = null;
// Dispose of the call connection
call?.dispose();
call = null;
// Dispose of broadcaster's player
broadcasterPlayer?.dispose();
broadcasterPlayer = 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 user navigates
* away or closes the tab.
*
* 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() {
window.addEventListener('beforeunload', () => {
cleanupPage('Page unload');
});
}
/**
* Application Entry Point
*
* This runs when the page finishes loading. It calls init() to set up
* the direct streaming viewer.
*/
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;
}
/**
* Player Utilities Module
*
* This module provides utility functions for creating and managing manifest player UI.
* It handles:
* - Creating video elements and playback controls
* - Appending player UI to the DOM
* - Attaching event handlers for player controls
* - Removing event handlers on cleanup
*
* Key Concepts:
* - Player: The video player instance that manages playback of manifest streams
* - Video Element: The HTML <video> tag where the stream is displayed
* - Playback Controls: Buttons and inputs for play/pause, mute, and volume control
*/
/**
* Attach Event Handlers to Player Controls
*
* Sets up click and change event handlers for all player UI controls.
* This enables users to:
* - Play/pause video playback
* - Mute/unmute audio
* - Adjust volume level
*
* @param {Player} player - The player instance to control
* @param {string} id - Unique identifier for the video element and its controls
*
* The player object provides these key properties:
* - localVideoPaused: Boolean indicating if video is paused
* - localAudioMuted: Boolean indicating if audio is muted
* - localAudioVolume: Number (0-1) representing volume level
*/
function attachPlayerClickHandlers(player, id) {
/**
* Toggle Play/Pause
*
* Toggles between playing and pausing the video stream.
* The player.localVideoPaused property indicates current playback state.
*
* @param {Event} event - Click event from the play button
*/
async function togglePlay(event) {
if (player.localVideoPaused) {
// Resume playback
await player.play();
event.target.textContent = "Pause";
} else {
// Pause playback
await player.pause();
event.target.textContent = "Play";
}
}
/**
* Toggle Mute/Unmute
*
* Toggles audio muting on and off.
* When muted, the video continues playing but no audio is heard.
*
* @param {Event} event - Click event from the mute button
*/
async function toggleMute(event) {
if (player.localAudioMuted) {
// Unmute audio
await player.unmute();
event.target.textContent = "Mute";
} else {
// Mute audio
await player.mute();
event.target.textContent = "Unmute";
}
}
/**
* Handle Volume Change
*
* Adjusts the audio volume based on slider input.
* Volume is converted from 0-100 range to 0-1 range.
* Setting volume to 0 automatically mutes the player.
*
* @param {Event} ev - Change event from the volume slider
*/
function handleVolume(ev) {
const volumeValue = Number(ev.target.value);
if (volumeValue === 0) {
// Mute when volume is set to 0
player.localAudioMuted = true;
} else {
// Unmute when volume is above 0
player.localAudioMuted = false;
}
// Set volume (convert from 0-100 to 0-1)
player.localAudioVolume = volumeValue / 100;
}
/**
* Wire Up Event Listeners
*
* Connect the handler functions to the corresponding DOM elements.
* Sets initial button text to reflect starting player state.
*/
document.getElementById(`playBtn-${id}`).onclick = togglePlay;
document.getElementById(`playBtn-${id}`).textContent = "Pause"; // Default state is playing
document.getElementById(`muteBtn-${id}`).onclick = toggleMute;
document.getElementById(`muteBtn-${id}`).textContent = "Mute"; // Default state is unmuted
document.getElementById(`volume-${id}`).onchange = handleVolume;
}
/**
* Remove Event Listeners from Player Controls
*
* Cleans up event listeners when the player is disposed.
* This prevents memory leaks and ensures proper resource cleanup.
*
* @param {string} id - Unique identifier for the video element and its controls
*
* Important: Always call this function before disposing of a player to
* properly clean up event handlers and prevent memory leaks.
*/
function removePlayerClickHandlers(id) {
// Remove event listeners from all control elements
document.getElementById(`playBtn-${id}`).removeAllListeners();
document.getElementById(`muteBtn-${id}`).removeAllListeners();
document.getElementById(`volume-${id}`).removeAllListeners();
}
/**
* Generate HTML for Video Player and Controls
*
* Creates the HTML structure for the video element and playback controls.
* This includes:
* - Video element for displaying the stream
* - Play/pause toggle button
* - Mute/unmute toggle button
* - Volume slider control
*
* @param {string} id - Unique identifier for the video element and its controls
* @returns {string} HTML string containing the player UI structure
*
* The generated HTML will be injected into the videoContainer div.
* Each control element is given a unique ID based on the provided id parameter,
* allowing multiple players on the same page.
*/
function createVideoElement(id) {
return `
<div id="video-wrapper-${id}">
<!-- Video element where the stream will be displayed -->
<video
width="100%"
height="100%"
id="${id}"
>
</video>
<!-- Play/pause toggle button -->
<button id="playBtn-${id}"></button>
<!-- Mute/unmute toggle button -->
<button id="muteBtn-${id}"></button>
<!-- Volume control slider -->
<div class="volume-container">
<label for="volume-${id}">Volume: </label>
<input type="range" id="volume-${id}" min="0" max="100" value="50" />
</div>
</div>
`;
}
/**
* Append Video Player to DOM
*
* Injects the player UI into the page and returns a reference to the video element.
* This function:
* 1. Finds the video container div
* 2. Inserts the player HTML
* 3. Returns the video element for player attachment
*
* @param {string} id - Unique identifier for the video element
* @returns {HTMLVideoElement} Reference to the video element
*
* The video element is returned so the Player can be attached to it using
* player.attachTo(videoElement).
*/
function appendVideoElement(id) {
// Find the container where we'll inject the player UI
const videoContainer = document.getElementById("videoContainer");
// Insert the player HTML into the container
videoContainer.insertAdjacentHTML("beforeend", createVideoElement(id));
// Return reference to the video element
return document.getElementById(id);
}
/**
* Export Utility Functions
*
* These functions are used by the main manifest-player.js module to
* create and manage the player UI.
*/
export { attachPlayerClickHandlers, removePlayerClickHandlers, appendVideoElement };
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
| Feature | Direct Streaming Viewer | Basic WebRTC Viewer | Manifest Player |
|---|---|---|---|
| Connection | Manifest player with WebRTC driver | Explicit callId join | Transcoded streams |
| Call Joining | Automatic via joinedCall event | Explicit | None |
| Broadcast Back | Yes (encoder setup) | No | No |
| Communication | Bidirectional | View only | View only |
| Use Case | 1:1 interactive session | Multi-party viewing | Higher latency, no interaction |
Next Steps
To learn more about advanced streaming concepts:
- Explore Broadcasting a Livestream
- Learn about WebRTC Call Viewing