Stream Vision Camera to Fishjam Mobile
This tutorial is exclusively for Mobile (React Native) applications.
Fishjam manages the camera itself with useCamera, and can blur the background or run your own shaders on it. If you don't need VisionCamera for something else, follow the background blur tutorial or the WebGPU effects tutorial instead.
This tutorial walks you through publishing a VisionCamera feed to a Fishjam room, step by step. By the end, your camera frames will reach other participants as a custom video track, the same path you'd later use to run frame processors or draw your own effects into the published video.
What you'll buildβ
A React Native app that joins a room and publishes the device camera through VisionCamera, with a live self-view.
What you'll learnβ
- How to install and configure VisionCamera v5 alongside the Fishjam SDK
- How to publish camera frames with
useVisionCameraSource - How custom video tracks appear to other participants
Prerequisitesβ
- The React Native Quick Start completed, or an existing app wrapped in
FishjamProvider - A physical device, or the iOS Simulator with a virtual camera from SimCam
- The New Architecture enabled; custom video tracks require it
Step 1: Install and configureβ
Install the packagesβ
- npm
- Yarn
- pnpm
- Bun
npm install @fishjam-cloud/react-native-vision-camera-source react-native-vision-camera react-native-vision-camera-worklets react-native-worklets
yarn add @fishjam-cloud/react-native-vision-camera-source react-native-vision-camera react-native-vision-camera-worklets react-native-worklets
pnpm add @fishjam-cloud/react-native-vision-camera-source react-native-vision-camera react-native-vision-camera-worklets react-native-worklets
bun add @fishjam-cloud/react-native-vision-camera-source react-native-vision-camera react-native-vision-camera-worklets react-native-worklets
Configure the Babel pluginβ
VisionCamera's frame outputs run as worklets, which need the react-native-worklets Babel plugin. Add it as the last plugin:
module.exports = { presets: ["babel-preset-expo"], plugins: ["react-native-worklets/plugin"], };
Restart Metro with a cleared cache afterwards (npx expo start --clear).
Configure camera permissionβ
- Expo
- Bare workflow
{ "expo": { "android": { "permissions": ["android.permission.CAMERA"] }, "ios": { "infoPlist": { "NSCameraUsageDescription": "Allow $(PRODUCT_NAME) to access your camera." } } } }
<uses-permission android:name="android.permission.CAMERA"/>
<key>NSCameraUsageDescription</key> <string>Allow $(PRODUCT_NAME) to access your camera.</string>
Rebuild the appβ
- Expo
- Bare workflow
npx expo prebuild npx expo run:ios # or run:android
cd ios && pod install
Step 2: Join a roomβ
As in the quick start, join a room with a peer token. This time there is no need to initialize the camera through the Fishjam SDK, since VisionCamera will manage the camera:
importReact from "react"; import {Button } from "react-native"; import {useConnection ,useSandbox } from "@fishjam-cloud/react-native-client"; constSANDBOX_API_URL = "YOUR_SANDBOX_API_URL"; export functionJoinRoomButton () { const {joinRoom } =useConnection (); const {getSandboxPeerToken } =useSandbox ({sandboxApiUrl :SANDBOX_API_URL , }); consthandleJoinRoom = async () => { // For testing with the Sandbox API, use getSandboxPeerToken // For production apps, get the peerToken from your own backend instead constpeerToken = awaitgetSandboxPeerToken ("testRoom", "streamer"); awaitjoinRoom ({peerToken }); }; return <Button title ="Join Room"onPress ={handleJoinRoom } />; }
Step 3: Publish the cameraβ
useVisionCameraSource creates the video track, publishes it under the given source ID, and cleans up on unmount. Plug its frameOutput into VisionCamera's useCamera:
importReact , {useEffect } from "react"; import {Text } from "react-native"; import {useCamera asuseVisionCamera ,useCameraPermission , } from "react-native-vision-camera"; import {RTCView } from "@fishjam-cloud/react-native-client"; import {useVisionCameraSource } from "@fishjam-cloud/react-native-vision-camera-source"; export functionCameraPublisher () { const {hasPermission ,canRequestPermission ,requestPermission } =useCameraPermission ();useEffect (() => { if (!hasPermission &&canRequestPermission ) {requestPermission (); } }, [hasPermission ,canRequestPermission ,requestPermission ]); const {frameOutput ,stream } =useVisionCameraSource ("vision-camera");useVisionCamera ({device : "front",isActive :hasPermission ,outputs : [frameOutput ], }); if (!stream ) return <Text >Starting cameraβ¦</Text >; return ( <RTCView mediaStream ={stream }style ={{height : 300,width : 300 }}objectFit ="cover"mirror ={true} /> ); }
The stream also serves as your self-view. Frames are handed to Fishjam without copying pixels, and rotation and timestamps are handled for you.
Step 4: Display it on the other sideβ
Because the feed is a custom source, other participants find it in customVideoTracks, not in cameraTrack:
importReact from "react"; import {View } from "react-native"; import {usePeers ,RTCView } from "@fishjam-cloud/react-native-client"; export functionRemoteStreams () { const {remotePeers } =usePeers (); return ( <View > {remotePeers .map ((peer ) =>peer .customVideoTracks .map ( (track ) =>track .stream && ( <RTCView key ={track .trackId }mediaStream ={track .stream }style ={{height : 300,width : 300 }}objectFit ="cover" /> ), ), )} </View > ); }
To verify end to end, join the same room from a second device or another client built in the quick start, and watch the published feed arrive.
Complete exampleβ
functionCameraPublisher () { const {hasPermission ,canRequestPermission ,requestPermission } =useCameraPermission ();useEffect (() => { if (!hasPermission &&canRequestPermission ) {requestPermission (); } }, [hasPermission ,canRequestPermission ,requestPermission ]); const {frameOutput ,stream } =useVisionCameraSource ("vision-camera");useVisionCamera ({device : "front",isActive :hasPermission ,outputs : [frameOutput ], }); if (!stream ) return <Text >Starting cameraβ¦</Text >; return ( <RTCView mediaStream ={stream }style ={styles .video }objectFit ="cover"mirror ={true} /> ); } functionRoom () { const {joinRoom ,peerStatus } =useConnection (); const {remotePeers } =usePeers (); const {getSandboxPeerToken } =useSandbox ({sandboxApiUrl :SANDBOX_API_URL , }); const [isJoined ,setIsJoined ] =useState (false); consthandleJoin = async () => { // For testing with the Sandbox API, use getSandboxPeerToken // For production apps, get the peerToken from your own backend instead constpeerToken = awaitgetSandboxPeerToken ("testRoom", "streamer"); awaitjoinRoom ({peerToken });setIsJoined (true); }; return ( <View style ={styles .container }> <Text style ={styles .title }>Vision Camera Γ Fishjam</Text > <Text style ={styles .status }>Status: {peerStatus }</Text > {!isJoined && <Button title ="Join Room"onPress ={handleJoin } />} <Text style ={styles .sectionTitle }>Your camera</Text > <CameraPublisher /> <Text style ={styles .sectionTitle }>Other participants</Text > {remotePeers .map ((peer ) =>peer .customVideoTracks .map ( (track ) =>track .stream && ( <RTCView key ={track .trackId }mediaStream ={track .stream }style ={styles .video }objectFit ="cover" /> ), ), )} </View > ); } conststyles =StyleSheet .create ({container : {flex : 1,padding : 20, },title : {fontSize : 24,fontWeight : "bold",marginBottom : 10, },status : {fontSize : 16,marginBottom : 20, },sectionTitle : {fontSize : 18,fontWeight : "600",marginVertical : 10, },video : {height : 200,width : 200,borderRadius : 8, }, }); export default functionApp () { return ( <FishjamProvider fishjamId ={FISHJAM_ID }> <Room /> </FishjamProvider > ); }
Next stepsβ
- Run inference on the published frames; see Publish a Vision Camera feed
- Draw your own shaders into the VisionCamera feed; see WebGPU effects with VisionCamera
- Explore the custom sources overview and the low-level frame API
- Learn how custom sources work under the hood