Stream Vision Camera to Fishjam Mobile
This tutorial is exclusively for Mobile (React Native) applications.
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
- Continue this tutorial with Draw WebGPU effects into your published camera: a watermark overlay, then your own shaders (see the WebGPU effects how-to for reference)
- Explore the custom sources overview and the low-level frame API
- Learn how custom sources work under the hood