react-native-moq
React Native bindings for Media over QUIC (MoQ): live video, audio, and data tracks with sub-second latency, published to and consumed from a MoQ relay. Built on Software Mansion's moq-kit native library.
Requirements: React Native New Architecture (Fabric + TurboModules), iOS 16+, Android API 30+.
npm install react-native-moq
cd ios && pod install
Optional player chrome (fullscreen modal, controls, volume slider):
npm install react-native-moq-ui @react-native-vector-icons/material-icons
iOS also needs MaterialIcons.ttf under UIAppFonts in Info.plist, then pod install again. Skip this package if composing your own player UI on <VideoView>.
Mental model
- A Session is one connection to a MoQ relay (
useSession(url)). It does not auto-connect — callsession.connect()yourself. One session serves both subscribing and publishing. - Subscribing:
useBroadcasts(session, prefix)lists live broadcasts under a path prefix → pick aBroadcastInfo→useVideoPlayer/useAudioPlayer→ render with<VideoView player={player}>. Raw-media alternatives:useAudioChunks(encoded or PCM to JS) anduseDataMessages(string payloads). - Publishing: capture hooks produce tracks (
useCamera,useMicrophone,useMultiCamera) or you generate them (useDataTrack,useAudioSource,useVideoSource) →usePublisher(session).publish({ path, tracks }). Screen sharing (useScreenBroadcast) is separate and out-of-process. - Every hook has a hook-free counterpart —
create*(returns the object plusdestroy()) for the stateful ones,subscribe*(returnsstart()/stop()) for the subscription hooks — see references/imperative.md. - Objects expose reactive state (
session.state,publisher.state,player.isPlaying, …); hooks re-render on changes. Subscribe manually withaddListeneror theuseEvent/useEventListenerhelpers.
Quick start — watch
function Watch() {
const session = useSession('https://relay.example.com', (s) => s.connect());
const broadcasts = useBroadcasts(session, ''); // '' = all paths
return broadcasts.map((b) => <BroadcastPlayer key={b.path} broadcast={b} />);
}
function BroadcastPlayer({ broadcast }: { broadcast: BroadcastInfo }) {
const player = useVideoPlayer(broadcast, (p) => p.play());
return <VideoView player={player} style={{ width: '100%', aspectRatio: 16 / 9 }} />;
}
useVideoPlayer/useAudioPlayer require a non-null broadcast — mount them in a child rendered only once one exists.
Quick start — go live
const session = useSession(url, (s) => s.connect());
const camera = useCamera();
const microphone = useMicrophone();
const publisher = usePublisher(session);
<PublisherView camera={camera} style={styles.preview} />
// when session.state === 'connected':
publisher.publish({ path: 'live/test', tracks: [camera, microphone] });
Critical rules
- Errors are strings, not exceptions. States are unions like
'connected' | 'error:...'; checkstate.startsWith('error:'). Nothing throws on media failure. Sessions carry the message only in the state string (state.slice(6)); capture tracks and the publisher additionally expose alastErrorfield. Players have no error surface at all — watch session and publisher state instead. publish()requiressession.state === 'connected', otherwise the publisher goes straight toerror:session is not connected. Gate the go-live button on session state.- Gate codecs with
getSupportedVideoCodecs()/getSupportedAudioCodecs(). On Android an unsupported encoder (oftenh265) makes publishing start then silently stop with no error. Default toh264/opus. - Permissions are the host app's job — request CAMERA/RECORD_AUDIO on Android, add NSCameraUsageDescription/NSMicrophoneUsageDescription on iOS. A black preview usually means a missing permission.
<VideoView>takes no children (the Android native view is not a ViewGroup). Render overlays as absolutely-positioned siblings.- Capture hooks are refcounted device singletons — all consumers share the physical camera/mic;
flip()affects everyone. Useenabled: falseto keep hardware off while mounted instead of calling hooks conditionally. - Broadcast paths are relative to the subscription prefix (broadcast
live/testappears aspath: 'test'under prefix'live'). Subscribe with''to see full paths. A listed broadcast that won't play (relay errorcode=13NotFound) means a path/prefix mismatch. - Data tracks are not in the catalog — publisher and subscriber must agree on the track name out of band (default
'data'). - An idle iOS microphone capture holds the audio session in
playAndRecordand can block other audio libraries (insufficientPriority). Disable the mic (enabled: false) when not publishing.
References
| Task | Read |
|---|---|
Sessions, broadcast discovery, video/audio playback, <VideoView>, stats, track switching, events, react-native-moq-ui | references/playback.md |
| Camera, multi-camera, microphone, publisher lifecycle, screen broadcasting, codec queries | references/publishing.md |
| Data tracks & messages, audio chunks to JS, push-your-own audio (PCM) and video (frame buffers) | references/custom-tracks.md |
Using without React: create* handles, subscribe* functions, cleanup | references/imperative.md |