Skip to main content
Version: 0.29.0

React Native Subscribing

This tutorial explains how to subscribe to a live stream and render it in a React Native mobile app using Media over QUIC (MoQ) with Fishjam. To broadcast a stream instead, see React Native Publishing.

It uses react-native-moq — React Native bindings for MoQKit, with a small, reactive hooks-based API. For the web equivalent, see Web Subscribing.

info

If you're new to MoQ, then we recommend getting familiar with the MoQ with Fishjam explanation.

Requirements​

react-native-moq targets the React Native New Architecture (Fabric / TurboModules):

  • iOS 16+
  • Android API 30+

Installation​

npm install react-native-moq

Then install the iOS pods:

cd ios && pod install
tip

The ready-made player chrome — <VideoPlayerView> with fullscreen controls, a volume slider, and matching context hooks — lives in a separate package so apps that build their own UI don't pay for it:

npm install react-native-moq-ui @react-native-vector-icons/material-icons

This tutorial uses the bare <VideoView> from the core package, but <VideoPlayerView> is a drop-in replacement if you want controls out of the box.

Quickstart with the Sandbox API​

If you don't have a backend server set up, you can prototype subscribing using the Sandbox API.

tip

MoQ is a protocol with a well-defined negotiation, so a publisher and a subscriber don't need to use the same client library. A stream published from the browser with @moq/publish can be watched with react-native-moq, and vice versa.

Obtaining a subscriber connection URL​

For more on what the Sandbox API is and its limitations, see What is the Sandbox API?.

info

To obtain a MoQ connection URL you'll need your Sandbox API URL. If you don't have it already, see Sandbox API URL.

The useSandbox hook from @fishjam-cloud/react-native-client wraps the Sandbox API request for you:

import { useSandbox } from "@fishjam-cloud/react-native-client"; const SUBSCRIBER_PATH = "stream-alice"; const SANDBOX_API_URL = "YOUR_SANDBOX_API_URL"; // Inside a React component: const { getSandboxMoqSubscriberAccess } = useSandbox({ sandboxApiUrl: SANDBOX_API_URL, }); // Request a subscriber connection URL scoped to the subscriber path const { connection_url: subscribeUrl } = await getSandboxMoqSubscriberAccess(SUBSCRIBER_PATH);

Connecting and subscribing​

Open a session, discover the broadcast under its path with useBroadcasts, and render it with <VideoView>. useVideoPlayer turns a discovered broadcast into a reactive player; the decoding and rendering pipeline is handled natively — no canvas, no manual WebCodecs setup:

import { Button } from "react-native"; import { VideoView, useBroadcasts, useSession, useVideoPlayer, } from "react-native-moq"; import type { BroadcastInfo } from "react-native-moq"; const SUBSCRIBER_PATH = "stream-alice"; const subscribeUrl = ""; // from the step above function WatchScreen() { // Connect to the Fishjam MoQ relay on mount using the subscriber connection URL const session = useSession(subscribeUrl, (s) => s.connect()); // Discover broadcasts under the subscriber path const broadcasts = useBroadcasts(session, SUBSCRIBER_PATH); return ( <> {broadcasts.map((broadcast) => ( <BroadcastPlayer key={broadcast.path} broadcast={broadcast} /> ))} </> ); } function BroadcastPlayer({ broadcast }: { broadcast: BroadcastInfo }) { // Create a reactive player and start playback const player = useVideoPlayer(broadcast, (p) => p.play()); return ( <> <VideoView player={player} style={{ width: "100%", aspectRatio: 16 / 9 }} /> <Button title={player.isPlaying ? "Pause" : "Resume"} onPress={player.isPlaying ? player.pause : player.play} /> </> ); }

That's it! The stream appears in the <VideoView> once the publisher starts broadcasting, and useBroadcasts re-populates automatically on reconnect.

info

No canvas needed.
Unlike the web, where Watch.MultiBackend decodes frames through WebCodecs and paints them onto a <canvas>, the React Native player decodes and renders natively. You just hand the player to a <VideoView> and size it like any other view.

For audio-only streaming, use useAudioPlayer(broadcast) instead of useVideoPlayer — the video track is never subscribed, so no video bandwidth is consumed.

Using the <VideoPlayerView> component​

If you don't need fine-grained control over the rendering surface, the react-native-moq-ui package ships a ready-to-use <VideoPlayerView> that wraps <VideoView> with platform-styled play/pause, a volume slider, and a fullscreen modal — the React Native counterpart of the web <moq-watch-ui> element.

import { VideoPlayerView } from "react-native-moq-ui"; function BroadcastPlayer({ broadcast }: { broadcast: BroadcastInfo }) { const player = useVideoPlayer(broadcast, (p) => p.play()); return ( <VideoPlayerView player={player} style={{ width: "100%", aspectRatio: 16 / 9 }} /> ); }

It exposes imperative enterFullscreen() / exitFullscreen() methods on its ref, and the chrome is fully customizable. See Default UI components in the API reference.

Production with Server SDKs​

The Quickstart gets you watching quickly. In production, your backend generates tokens with proper authorization, so you control who can subscribe.

A subscriber connection URL grants read access to a specific path. Generate one on your backend and deliver it to the viewing client:

import { FishjamClient } from '@fishjam-cloud/js-server-sdk'; const fishjamClient = new FishjamClient({ fishjamId, managementToken, }); const streamPath = 'stream-alice'; // Generate a connection URL that allows subscribing to 'stream-alice' const { connection_url: subscribeUrl } = await fishjamClient.createMoqAccess({ subscribePath: streamPath, });

Deliver this connection URL to the mobile client, then use it to connect as described in Connecting and subscribing.

Subscribe to a namespace​

When multiple publishers join a room, you won't know their exact paths in advance. Instead of consuming a single path, you can discover all broadcasts published under a namespace prefix and subscribe to each one as they appear.

To do this, generate a subscriber connection URL scoped to the room namespace instead of a single stream path.

import { FishjamClient } from '@fishjam-cloud/js-server-sdk'; const fishjamClient = new FishjamClient({ fishjamId, managementToken }); const roomName = 'my-room'; const { connection_url: alicePublisherUrl } = await fishjamClient.createMoqAccess({ publishPath: roomName + "/alice", }); const { connection_url: bobPublisherUrl } = await fishjamClient.createMoqAccess({ publishPath: roomName + "/bob", }); const { connection_url: namespaceUrl } = await fishjamClient.createMoqAccess({ subscribePath: roomName, });

On the client, pass the namespace connection URL to useSession, then call useBroadcasts(session, roomName) with the room prefix. It returns a reactive BroadcastInfo[] that re-renders every time a publisher joins or leaves — no manual diffing of an announce set. Map over it to mount a player per broadcast:

import { VideoView, useBroadcasts, useSession, useVideoPlayer, } from "react-native-moq"; import type { BroadcastInfo } from "react-native-moq"; const ROOM_NAME = "my-room"; const namespaceUrl = ""; function RoomGrid() { const session = useSession(namespaceUrl, (s) => s.connect()); // Every broadcast published under the 'my-room' prefix, kept live const broadcasts = useBroadcasts(session, ROOM_NAME); return ( <> {broadcasts.map((broadcast) => ( <Tile key={broadcast.path} broadcast={broadcast} /> ))} </> ); } function Tile({ broadcast }: { broadcast: BroadcastInfo }) { const player = useVideoPlayer(broadcast, (p) => p.play()); return ( <VideoView player={player} style={{ width: "100%", aspectRatio: 16 / 9 }} /> ); }

Because useBroadcasts starts the native subscription on mount and tears it down on unmount, players appear and disappear in lockstep with the publishers in the room — React's reconciliation handles mounting and unmounting tiles for you.

See also​