react-native-nitro-rtmp-publisher
v0.12.0
Published
RTMP/RTMPS live streaming camera for React Native (iOS, Android, Expo). Hardware-accelerated H.264/HEVC, adaptive bitrate, auto-reconnect. Stream to YouTube Live, Facebook Live, Twitch, Instagram.
Maintainers
Keywords
Readme
Why this library
react-native-nitro-rtmp-publisher is the most up-to-date React Native RTMP / RTMPS broadcasting library — first-class Expo support, full New Architecture (Fabric) compatibility, and a tiny, fully-typed JS surface generated by Nitrogen.
Live streaming from a phone is two hard problems entangled — owning the camera capture pipeline without choking the React Native UI thread, and pushing encoded H.264 over a flaky mobile network. We solve both natively and only touch the JS bridge on real state transitions.
- Native preview view — Camera2/Metal + GPU compositor on the C++ thread. Zero React Native UI thread cost per frame.
- Hardware H.264/HEVC encoding —
MediaCodecon Android,VideoToolboxon iOS. - Adaptive bitrate built in — measures TX throughput, drops on congestion, recovers on headroom. Or take manual control with
setVideoBitrateOnFly. - Auto-reconnect — opt-in retry budget that survives the mobile network's mood swings.
- Type-safe API — generated end-to-end by Nitrogen. No JSON serialization on the hot path.
- Opt-in callbacks — per-second bitrate callback is off by default. Subscribe only if you need it.
- Cross-platform parity — same JS API on iOS and Android. Platform differences live inside the library, never in your app code.
Table of contents
- Install
- Quickstart
- Permissions
- iOS: Agora & legacy RTMP servers
- Props
- Methods
- Platform parity
- Performance notes
- Architecture
- Common pitfalls
- Contributing
- FAQ
- Acknowledgements
- License
Install
npm install react-native-nitro-rtmp-publisher react-native-nitro-modules
# or
yarn add react-native-nitro-rtmp-publisher react-native-nitro-modulesiOS — Expo (one-liner)
Add the package's Expo config plugin to your app.json (or app.config.js) and re-run expo prebuild:
{
"expo": {
"plugins": ["react-native-nitro-rtmp-publisher"]
}
}That's it. The plugin will inject the two vendored HaishinKit + RTMPHaishinKit pod entries into your generated ios/Podfile (idempotent — re-running prebuild won't duplicate them). It will not touch your Info.plist unless you opt in explicitly.
You still need to declare camera + mic usage strings yourself. The usual place is expo.ios.infoPlist in app.json:
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "Stream live video from your camera.",
"NSMicrophoneUsageDescription": "Capture audio for live streams.",
"UIBackgroundModes": ["audio"]
}
}If you'd rather the plugin set the camera / mic strings (handy when this library is the only camera consumer in your app), pass them as props — the plugin only writes Info.plist keys when a prop is supplied:
"plugins": [
["react-native-nitro-rtmp-publisher", {
"ios": {
"cameraUsage": "Stream live video from your camera.",
"microphoneUsage": "Capture audio for live streams."
}
}]
]If neither prop is passed, Info.plist is left untouched.
Plugin options are grouped by platform. Pass an
iosand/orandroidsub-object for platform-specific options; any key at the top level is treated as common and applied to both platforms (a platform key overrides a common key of the same name). Available options:ios.cameraUsage,ios.microphoneUsage,ios.legacyRtmpCompatibility(see iOS: Agora & legacy RTMP servers),enablePictureInPicture(common — arms system Picture-in-Picture on iOS and Android in one key: adds thevoip+audioUIBackgroundModeson iOS and the activity manifest flags on Android; see Picture-in-Picture), andandroid.disableForegroundService(see Android: opting out of the foreground service).
iOS — bare React Native (manual Podfile)
If you're not on Expo, add the two vendored HaishinKit podspecs to your ios/Podfile (inside your target block), then pod install:
target 'YourApp' do
# … your existing config …
pod 'HaishinKit', :podspec => '../node_modules/react-native-nitro-rtmp-publisher/podspecs/HaishinKit.podspec'
pod 'RTMPHaishinKit', :podspec => '../node_modules/react-native-nitro-rtmp-publisher/podspecs/RTMPHaishinKit.podspec'
endWhy two extra lines? Upstream HaishinKit stopped publishing podspecs to CocoaPods after 2.0.9 (they moved fully to SPM). This package vendors podspecs that point at the current 2.2.x GitHub tag so CocoaPods consumers can still get the latest HaishinKit without forking. We pin both pods at the same version so cross-module
package-level symbols resolve.
Add the privacy descriptions to your Info.plist so iOS can prompt the user the first time the publisher accesses the camera / microphone:
<key>NSCameraUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to use the camera for live streaming.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to use the microphone for live streaming.</string>If you want streaming to continue while the user briefly backgrounds the app, also add:
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>Minimum: iOS 15 (HaishinKit 2.2.x requirement). Xcode: 15+ recommended. If you need iOS 13/14 support, pin to v0.3.x of this package (HaishinKit 1.9.x).
Android
The library declares CAMERA, RECORD_AUDIO, INTERNET, ACCESS_NETWORK_STATE, WAKE_LOCK, and the foreground-service permissions in its own manifest — manifest merging picks these up automatically, so you don't have to repeat them.
You still need to request runtime permissions before mounting the view (see Permissions).
Minimum: minSdkVersion 21. Kotlin: 1.9+.
Quickstart
import { useEffect, useMemo, useRef, useState } from 'react'
import { Alert, StyleSheet, Button, View } from 'react-native'
import { callback } from 'react-native-nitro-modules'
import {
RtmpPublisherView,
requestRtmpPermissions,
type RtmpPublisherViewMethods,
} from 'react-native-nitro-rtmp-publisher'
export default function App() {
const publisher = useRef<RtmpPublisherViewMethods | null>(null)
const [ready, setReady] = useState(false)
// One platform-agnostic permission call — works on iOS and Android.
useEffect(() => {
requestRtmpPermissions().then(({ granted }) => {
if (!granted) Alert.alert('Camera + microphone permissions required')
setReady(granted)
})
}, [])
const hybridRef = useMemo(
() => callback((ref: RtmpPublisherViewMethods) => {
publisher.current = ref
// Configure encoder BEFORE startPreview (avoids preview/stream race).
const rotation = ref.getCameraOrientation()
ref.prepareVideo(1280, 720, 30, 2_500_000, 2, rotation)
ref.prepareAudio(128_000, 44_100, true)
ref.startPreview('back', 1280, 720)
// 5 retries, 3s backoff — survives momentary network drops.
ref.setAutoReconnect(5, 3000)
// Adaptive bitrate: cap at 2.5 Mbps, drop 20% on congestion, recover 5%.
ref.setAdaptiveBitrate(2_500_000, 20, 5)
ref.setOnConnectionEvent((event, msg) => {
console.log('rtmp:', event, msg)
})
}),
[]
)
if (!ready) return <View />
return (
<View style={{ flex: 1 }}>
<RtmpPublisherView
style={StyleSheet.absoluteFill}
videoCodec="h264"
audioCodec="aac"
aspectRatioMode="adjust"
audioSource="camcorder"
streamMode="balanced"
hybridRef={hybridRef}
/>
<Button title="Go live" onPress={() =>
publisher.current?.startStream('rtmp://your-ingest/app/streamKey')
} />
</View>
)
}Important — wrap the
hybridRefcallback inuseMemo([], …). If the callback identity changes between renders, the view re-initializes on every render and the camera will thrash open/close.
A fuller example (pinch-to-zoom, event sheet, thermal chip, auto-reconnect) lives in example/.
Permissions
The library ships a single requestRtmpPermissions() helper that works on both platforms:
import { requestRtmpPermissions } from 'react-native-nitro-rtmp-publisher'
const { granted, camera, microphone, notifications } = await requestRtmpPermissions()
if (!granted) {
// surface a UI prompting the user to enable in Settings
}
if (notifications === 'denied') {
// Android 13+ only — optional but recommended.
// Stream still works, but the foreground-service notification will not
// appear and some OEMs may suspend the background stream.
}- Android: shows the standard
PermissionsAndroidruntime dialog forCAMERA+RECORD_AUDIO. On Android 13+ (API 33), also requestsPOST_NOTIFICATIONSin the same prompt batch so the FG-service notification can actually appear when streaming with a non-emptyforegroundServiceTitle. Thenotificationsfield reports the result —'unknown'on Android ≤ 12 (the permission didn't exist as a runtime grant) and on iOS. - iOS: resolves to
granted: trueimmediately. iOS shows the system permission sheet automatically the first timeAVCaptureDeviceopens the camera or microphone — the publisher view stays idle until the user accepts, so it's safe to mount before the user has decided.
If you want to roll your own permission flow (e.g. using expo-camera + expo-notifications), you can — just make sure all three permissions (camera, microphone, notifications on Android 13+) are granted before mounting <RtmpPublisherView> on Android. On iOS you can mount anytime.
Android: opting out of the foreground service
By default the library declares these in its AndroidManifest, which auto-merge into your app's APK:
FOREGROUND_SERVICE,FOREGROUND_SERVICE_CAMERA,FOREGROUND_SERVICE_MICROPHONEPOST_NOTIFICATIONS- A
<service>declaration forRtmpForegroundService
These let <RtmpPublisherView> keep camera/mic active while the app is in the background — required on Android 14+, otherwise the OS revokes both within ~5 seconds of backgrounding.
The trade-off: any app whose merged manifest carries FOREGROUND_SERVICE_CAMERA or _MICROPHONE has to fill the Play Console "Foreground services" declaration form (with a demo video) before it can publish, whether or not the service is actually used at runtime.
If your app is foreground-only — kiosk mode, in-app demos, internal tools, anything where the user is always looking at the streaming UI — you can opt out at build time and skip the Play Console form entirely. The library publishes a stripped manifest variant for exactly this case.
Bare React Native
Add this to your app's android/gradle.properties:
nitroRtmpPublisherFgs=falseThat's it. The next build uses the stripped manifest — no FGS permissions, no <service> declaration. The foregroundServiceTitle / foregroundServiceText / foregroundServiceIcon props become no-ops; the library logs one warning the first time startStream is called with a non-empty title so you know it's being ignored.
Expo (managed)
Pass the option to the config plugin:
{
"plugins": [
["react-native-nitro-rtmp-publisher", {
"android": {
"disableForegroundService": true
}
}]
]
}expo prebuild writes the gradle property for you. Re-run prebuild after toggling.
Trade-off you're accepting
- ✅ No Play Console FGS form required.
- ✅ Smaller permission set in the user's install prompt + system Settings → Apps view.
- ❌ Streams die when the app backgrounds on Android 14+ (camera/mic revoked by the OS). Lock screen, app switcher, push-notification tap — all kill the stream.
- ❌ The library's compiled
RtmpForegroundServiceclass is still in the AAR as dead code (~3 KB). Not worth shipping two AAR variants to remove.
The flag is build-time only. If you later need background streaming, you have to publish a new build (manifest entries are baked into the binary). There's no JS-runtime "enable FGS" path.
iOS: streaming to Agora or other servers with a legacy RTMP architecture
TL;DR — If your iOS stream takes ~15–17 s to connect to Agora (or any ingest built on a legacy RTMP / Adobe Flash Media Server architecture) while the same app connects in 2–4 s everywhere else, set
ios.legacyRtmpCompatibility: truein the config plugin. It's off by default; most servers don't need it.
Symptom (iOS only). Publishing takes ~15–17 seconds to fire connectionSuccess, while the same app reaches the same event in 2–4 s on YouTube, Twitch, Facebook/Instagram Live, and OBS relays — and Android connects to the same URL in ~4 s. Once connected the stream is perfectly healthy; only the start is slow. The most common place this shows up is Agora's RTLS ingest (rtmp://rtls-ingress-*.agoramdn.com/live/…), but it can happen with any server that presents an old Flash-Media-Server-style RTMP front end.
Why it happens. These servers identify themselves as something like fmsVer = FMS/3,0,1,123 — i.e. they emulate a 2008-era Adobe Flash Media Server and expect the old FMLE (Flash Media Live Encoder) publish handshake. The iOS RTMP engine this library uses (HaishinKit) follows that handshake strictly: before it sends createStream / publish, it sends two FMLE-compatibility commands, releaseStream and FCPublish, and waits for the server to acknowledge them. Modern servers reply instantly. These legacy servers simply never reply to those two commands — so HaishinKit blocks on each until its internal request timeout (~15 s) expires, then continues. That timeout is the entire delay. OBS/librtmp and Android (the Pedro engine) send those same commands fire-and-forget and never wait for a reply, which is exactly why they're fast against the same server and iOS is not.
What this option changes, and why it's safe
When you enable ios.legacyRtmpCompatibility, the library applies a one-line-per-statement patch to HaishinKit's RTMPStream.createStream():
- async let _ = connection?.call("releaseStream", arguments: fcPublishName)
- async let _ = connection?.call("FCPublish", arguments: fcPublishName)
+ Task { _ = try? await connection?.call("releaseStream", arguments: fcPublishName) }
+ Task { _ = try? await connection?.call("FCPublish", arguments: fcPublishName) }The original async let _ bindings look fire-and-forget but aren't: Swift implicitly awaits an async let at the end of its enclosing scope, so createStream() couldn't proceed until both calls returned (or timed out). Wrapping each call in a detached Task makes it genuinely fire-and-forget — releaseStream/FCPublish are still sent (legacy servers that do care about them still receive them, in order, on the same RTMP chunk stream), but createStream/publish no longer block waiting on a reply. This is the same behavior OBS and the Android engine already use, and it drops the connect time from ~17 s to ~2–3 s. Against modern servers it's a no-op in practice: they were replying instantly anyway, so not awaiting the reply changes nothing observable.
Why it's opt-in / off by default. It's a source patch of a third-party dependency (HaishinKit), and the overwhelming majority of ingests connect fine without it. So we don't apply it unless you explicitly ask for it — enable it only if you publish to Agora RTLS or another legacy FMS/FMLE-style server that's slow to connect on iOS.
How to enable it
Expo (managed)
{
"plugins": [
["react-native-nitro-rtmp-publisher", {
"ios": {
"legacyRtmpCompatibility": true
}
}]
]
}Re-run expo prebuild and pod install after toggling. The plugin injects a post_install hook into ios/Podfile that applies the patch on every install. (It runs against the freshly-installed Pods/ tree, so unlike a podspec prepare_command it isn't affected by the CocoaPods download cache and stays per-app.)
Bare React Native (manual Podfile)
Add this inside the post_install do |installer| block of your ios/Podfile, then pod install:
post_install do |installer|
# … your existing react_native_post_install(…) call …
# Legacy-RTMP fix (Agora RTLS or any legacy FMS-style server): send
# releaseStream/FCPublish fire-and-forget so createStream isn't blocked
# ~15s on servers that never reply to them. Idempotent; no-op if upstream
# renames the lines.
rtmp_hk_stream = File.join(__dir__, 'Pods', 'RTMPHaishinKit', 'RTMPHaishinKit', 'Sources', 'RTMP', 'RTMPStream.swift')
if File.exist?(rtmp_hk_stream)
rtmp_src = File.read(rtmp_hk_stream)
rtmp_orig = rtmp_src.dup
{
'async let _ = connection?.call("releaseStream", arguments: fcPublishName)' =>
'Task { _ = try? await connection?.call("releaseStream", arguments: fcPublishName) }',
'async let _ = connection?.call("FCPublish", arguments: fcPublishName)' =>
'Task { _ = try? await connection?.call("FCPublish", arguments: fcPublishName) }',
}.each do |rtmp_from, rtmp_to|
next if rtmp_src.include?(rtmp_to)
rtmp_src = rtmp_src.sub(rtmp_from, rtmp_to) if rtmp_src.include?(rtmp_from)
end
# Only write when changed, and make the file writable first — CocoaPods
# lays pod sources down read-only on CI (e.g. EAS Build), so an
# unconditional File.write fails with EACCES.
if rtmp_src != rtmp_orig
File.chmod(0644, rtmp_hk_stream) rescue nil
File.write(rtmp_hk_stream, rtmp_src)
end
end
endIf you change the patch state, run
pod cache clean RTMPHaishinKit --all && rm -rf ios/Pods/RTMPHaishinKitbeforepod installso CocoaPods re-lays-down the source the hook runs against.
Props
All props are required (set them once in JSX; runtime mutations are honored where the underlying platform supports it).
| Prop | Type | Default | Description |
|-----------------------|---------------------------------------|-------------|-------------|
| forceHardwareCodec | boolean | true | Android-meaningful — pin both MediaCodec encoders to HARDWARE. Silent no-op on iOS (always uses VideoToolbox HW). |
| videoCodec | 'h264' \| 'h265' \| 'av1' | 'h264' | RTMP servers almost always require H.264. iOS supports h264/h265; Android adds av1. |
| audioCodec | 'aac' \| 'g711' \| 'opus' | 'aac' | RTMP standard is AAC. Other values are accepted for API parity but only AAC actually publishes over RTMP on iOS. |
| aspectRatioMode | 'fill' \| 'adjust' \| 'none' | 'adjust' | How the preview maps onto the view bounds. |
| mirrorPreview | boolean | false | Controls the publisher (on-screen) view only. On front camera, true = "viewer's perspective" (raise left → see right side of screen), false = selfie-mirror view. On back camera, the value applies literally as a horizontal flip. |
| mirrorStream | boolean | false | Controls the subscriber (encoded RTMP) view only. Fully independent of mirrorPreview — changing one never affects the other. Same front-camera semantics as mirrorPreview for cross-platform consistency. |
| thermalWarningThreshold | ThermalStatus | 'severe' | Minimum OS thermal level that fires setOnThermalWarning. 'none' disables monitoring entirely. |
| audioSource | 'mic' \| 'camcorder' \| 'voiceRecognition' \| 'voiceCommunication' \| 'unprocessed' | 'camcorder' | Capture mode. 'camcorder' is the right default for live streaming on both platforms. |
| noiseSuppression | boolean | false | Suppress steady background noise (fans, AC, hum, hiss) without phone-call AGC. Android: custom spectral denoiser (keeps music); iOS: Apple Voice Processing, AGC off. See Noise suppression. |
| autoRotateStream | boolean | true | Internal orientation observer auto-updates setStreamRotation as the device rotates. Disable for manual control. |
| streamMode | 'lowLatency' \| 'balanced' \| 'quality' | 'balanced' | Pipeline preset. See Long-form streams. |
| foregroundServiceTitle | string | '' | Android-only. If non-empty, a foreground service auto-starts on startStream and auto-stops on stopStream. Silently ignored on iOS (uses UIBackgroundModes from Info.plist instead). Setter is live: mid-stream changes refresh the running notification. |
| foregroundServiceText | string | '' | Notification text shown alongside foregroundServiceTitle. Live setter — change mid-stream for "Streaming · 12:34"-style timers. |
| foregroundServiceIcon | string | '' | Android-only. Drawable resource name (bare, no package or extension, e.g. 'ic_notification') used as the FG-service notification small icon. Resolved at runtime against the host app's res/drawable* then res/mipmap*. Falls back to a generic system icon when empty or unresolvable. Live setter — mid-stream changes refresh the notification. No-op on iOS. |
| pictureInPictureEnabled | boolean | false | Arm system Picture-in-Picture (auto-enters the floating window on background). The window uses the device aspect ratio. Android: live PIP on every device. iOS: live PIP only where the camera survives multitasking — iPhone iOS 18+ (host declares the voip background mode) / M1+ iPad; on older iOS the prop is a no-op (PIP disabled), since a frozen window + paused stream is a worse experience. See Picture-in-Picture. |
Mirror semantics — read this once. The two mirror props are fully independent:
mirrorPreviewonly touches the local preview,mirrorStreamonly touches the encoded stream. On the front camera both props use the "viewer's perspective" convention —truemeans show the camera output as someone facing you would see it (raise left, see right).falseflips to the selfie-mirror view. On the back camera they apply as literal horizontal flips. The platforms are aligned: the same JSX produces the same visual on iOS and Android. See example/App.tsx for the canonical setup (mirrorPreview={isFront} mirrorStream={isFront}gives matching publisher and subscriber views on both ends).
Methods
Call methods imperatively via hybridRef. The full ref type is RtmpPublisherViewMethods.
Lifecycle
| Method | Notes |
|---|---|
| prepareVideo(width, height, fps, bitrate, iFrameInterval, rotation): boolean | Configure video encoder. Call before startPreview. Dimensions follow the natural-landscape convention (e.g. 1280, 720) — the library rotates internally based on rotation. |
| prepareAudio(bitrate, sampleRate, isStereo): boolean | Configure audio encoder. Call before startPreview. On Android, isStereo=true is silently downgraded to mono if the device's built-in mic only supports mono — see Stereo capture. |
| startPreview(facing, width, height): void | Open camera, render frames into the view. |
| stopPreview(): void | Release camera. |
| startStream(url): void | Begin RTMP publish. Queues until the GL surface is ready, so calling it immediately after prepareVideo/prepareAudio is safe. |
| stopStream(): void | End RTMP publish. |
| setAuthorization(user, password): void | Set AMF credentials. Some Wowza / Nimble setups require this. On iOS, the credentials are spliced into the connect URL as rtmp://user:pass@host/... at the next startStream. |
| setStreamRotation(rotation): void | Update encoder rotation mid-session (0/90/180/270). |
| requestKeyFrame(): void | Force the next encoded frame to be an IDR. Debounced to ~1Hz. |
Status
| Method | Returns |
|---|---|
| isStreaming() | boolean |
| isOnPreview() | boolean |
| getCameraOrientation() | Recommended rotation (degrees) to pass to prepareVideo. |
| getStreamWidth() / getStreamHeight() | Currently configured encoder resolution. |
| getCurrentBitrate() | Configured target bitrate (bps). For measured TX rate, subscribe to setOnBitrateChange. |
Adaptive bitrate
The library samples the measured TX bitrate every second and adjusts the encoder via setVideoBitrateOnFly:
- decreases when congestion is detected (RTMP send-buffer fills up on Android; TX throughput drops on iOS),
- slowly recovers toward
maxBitratewhen the network has headroom.
// Cap at 2.5 Mbps. On congestion drop 20% of current bitrate; on recovery
// raise 5% per second.
ref.setAdaptiveBitrate(2_500_000, 20, 5)
// Disable
ref.setAdaptiveBitrate(0, 0, 0)maxBitrate should match (or be slightly above) the bitrate you passed to prepareVideo. The adapter resets to the ceiling at every fresh startStream.
For full manual control, subscribe to setOnBitrateChange and call setVideoBitrateOnFly(...) yourself.
Live fps + bitrate together.
setOnStreamStats((bitrateBps, videoFps) => …)delivers both in one per-second callback (superset ofsetOnBitrateChange).bitrateBpsis the measured muxed TX rate;videoFpsis the live frame rate — the sent rate on Android (drops under congestion show here) and the encoder-input rate on iOS. Per-track audio/video bitrate isn't available — both engines only measure the combined throughput.
Auto-reconnect
Mobile networks drop. Auto-reconnect is ON by default — 5 attempts with a
2-second base backoff, on both platforms — so a transient mid-stream blip
recovers without any setup. Tune it, or opt out with (0, 0):
// Retry up to 8 times with a 2-second base backoff. Fires a `reconnecting`
// event before each attempt; when the budget runs out you'll get a final
// `connectionFailed`. Pass (0, 0) to disable auto-reconnect entirely.
ref.setAutoReconnect(8, 2000)On Android the backoff escalates (backoffMs · 2^attempt, clamped) so a dead or
rate-limiting server isn't hammered. The retry budget is re-seeded on every
fresh startStream(url); stopStream() aborts any in-flight retry. Every
recovery path emits a consistent reconnecting → connectionSuccess sequence
(network blip, silent stall, phone call, and background→foreground resume alike).
Silent-stall detection. Beyond socket errors, the library watches actual throughput: if no bytes reach the server for a few seconds while the stream still looks "live" — a half-open socket from a NAT idle timeout or a Wi-Fi↔LTE handover that fires no error — it forces a reconnect instead of sitting on a frozen stream, and reports the real (zero) bitrate during the stall rather than the configured one.
For manual retry control:
ref.setReTries(5) // budget for the session
const ok = ref.reTry(2000, 'manual') // returns false if budget exhaustedCamera selection
| Method | Notes |
|---|---|
| switchCamera() | Toggle front ⇄ back. |
| getCamerasAvailable(): string[] | Camera2 IDs on Android, AVCaptureDevice unique IDs on iOS. |
| getCurrentCameraId(): string | |
| switchCameraById(id): void | Select a specific lens (ultra-wide / tele / LiDAR-depth). |
| isFrontCamera(): boolean | |
Audio
| Method | Notes |
|---|---|
| setAudioMuted(muted): void | Keeps the audio track in the stream but emits silence. |
| isAudioMuted(): boolean | |
Torch
| Method | Notes |
|---|---|
| setLanternEnabled(on): void | No-op when unsupported (e.g. front camera). |
| isLanternEnabled(): boolean | |
| isLanternSupported(): boolean | |
Zoom
const min = ref.getMinZoom() // usually 1.0
const max = ref.getMaxZoom() // device-dependent
ref.setZoom(2.0)For pinch-to-zoom, hook RN's PanResponder to multi-touch and call setZoom(currentZoom * scale). See example/src/hooks/usePinchZoom.ts for a reference implementation.
Exposure
const min = ref.getMinExposure()
const max = ref.getMaxExposure()
ref.setExposure(0) // EV-compensation stepFocus
| Method | Notes |
|---|---|
| setAutoFocusEnabled(on): boolean | Returns whether it was applied. |
| isAutoFocusEnabled(): boolean | |
| setFocusDistance(d): void | Manual focus. Android: device units. iOS: lens position 0..1. |
Stabilization
| Method | Notes |
|---|---|
| setVideoStabilizationEnabled(on): boolean | Software stabilization. iOS: AVCaptureVideoStabilizationMode.standard. |
| setOpticalVideoStabilizationEnabled(on): boolean | Optical / cinematic stabilization, hardware-gated. iOS: .cinematic mode. |
| isVideoStabilizationEnabled() / isOpticalVideoStabilizationEnabled() | Current state. |
Beauty filter
Skin-smoothing applied per frame, affecting both the preview and the encoded stream. Fixed strength (no intensity parameter). Supported on both platforms (iOS and Android).
| Method | Notes |
|---|---|
| setBeautyFilterEnabled(on): void | Toggle the beauty filter. |
| isBeautyFilterEnabled(): boolean | Current state. |
ref.setBeautyFilterEnabled(true)- Android — a custom GPU shader (
WhiteningBeautyFilterRender) in the GL pipeline, tuned for a bright/fair look rather than the stock reddish tint. Precision is auto-selected: budget GPUs (≤ 8 GB RAM) use a cheapermediumpbuild (same look, kinder on bandwidth/thermals), and on capable deviceshighpauto-downgrades tomediumpunderseverethermal pressure, restoring on cooldown (see Thermals). - iOS — a CoreImage
VideoEffectregistered on the HaishinKit mixer: frequency-separation skin-smoothing (a GPU port of the Android high-pass shader, so it's edge-preserving — smooths skin without blurring eyes, hair, or detail), not a plain Gaussian blur. Equivalent result, but not pixel-identical to Android. Under sustained heat it auto-throttles — lighter smoothing + a smaller blur atserious, lighter still atcritical, restored on cooldown — the iOS analog of Android'shighp→mediumpdowngrade (see Thermals).
Local recording
Independent of streaming — can record while live, or without ever streaming.
const started = ref.startRecord('/path/to/clip.mp4')
// returns false if the path is invalid or the recorder is already running
ref.stopRecord()
// also: pauseRecord(), resumeRecord(), getRecordStatus()
ref.setOnRecordStatusChange((status) => console.log('record:', status))- Android: full lifecycle including pause/resume.
- iOS:
pauseRecord/resumeRecordare best-effort status transitions;AVAssetWriterhas no native pause primitive. The output file may have a small gap at the pause point.
Long-form streams
For broadcasts that run for hours, A/V can drift if the encoder's clock and the audio capture clock disagree. Use the quality preset:
<RtmpPublisherView streamMode="quality" ... />That preset:
- Android: forces monotonic incremental timestamps +
TimestampMode.CLOCK, enlarges the RTMP cache to ~4s, writes larger chunks, softens the ABR exponential factor. - iOS: enlarges the chunk size to 8192 bytes and elevates the connection QoS to
userInteractive. iOS encoders emit monotonic timestamps internally so no extra knob is needed.
If you need finer control, call the primitives directly:
ref.forceIncrementalTs(true) // Android only — iOS is intrinsic
ref.setStreamDelay(50) // Android only — +50ms audio delayFPS lock in low light
By default Camera2's auto-exposure can extend frame duration to brighten dark frames — your "30fps" stream becomes 15fps once the sun goes down. Lock the AE FPS range:
ref.setForceFpsLimit(true)Trade-off: darker night frames, but smooth motion. Recommended on for any sports / motion-heavy stream. iOS uses activeVideoMin/MaxFrameDuration for the same effect.
Audio source
The default Android MIC source is tuned for phone calls — AGC crushes dynamic range and the noise gate kills ambient sound. Pick a better source via the audioSource prop:
| Source | iOS mapping¹ | Android mapping | Use for |
|---|---|---|---|
| 'camcorder' | .videoRecording (light NR built in) | CAMCORDER | Default. Streaming, vlogs, anywhere voice + room mix |
| 'mic' | .default (natural, no DSP) | MIC (AGC + NS) | Talking heads — Android applies phone-call tuning, iOS gives natural mic input |
| 'voiceRecognition' | .measurement (raw) | VOICE_RECOGNITION (raw) | Clean signal, you handle mixing |
| 'voiceCommunication' | .voiceChat | VOICE_COMMUNICATION | Two-way streams with echo cancellation |
| 'unprocessed' | .measurement | UNPROCESSED (API 24+) | Music capture, pro-audio scenarios |
¹ When noiseSuppression={true}, iOS captures audio via the library's own AVAudioEngine + spectral denoiser instead of this audioSource→mode mapping — see Noise suppression.
Single biggest perceived audio-quality win available. Test with 'camcorder' first.
Noise suppression
The noiseSuppression prop suppresses steady background noise — fans, air-conditioners, appliance hum, traffic rumble, broadband hiss. Decoupled from audioSource so you can mix and match.
<RtmpPublisherView noiseSuppression={true} ... />Neither platform applies the AGC/voice-compression of a phone-call processor, so your voice keeps its natural level — it's safe to leave on for talk streams.
- Android: a custom spectral denoiser (decision-directed Ephraim–Malah Wiener filter with adaptive noise-floor tracking) on the mic PCM via RootEncoder's
setCustomAudioEffect. Targets the stationary noise floor, so a steady fan / AC is removed while voice and music pass through largely untouched. Applies live (toggle mid-stream). - iOS: Apple's Voice Processing (FaceTime/Siri-grade NS + echo cancellation) on an owned
AVAudioEnginecapture, with AGC disabled so the voice isn't leveled/compressed. HaishinKit has no audio-effect hook, so whilenoiseSuppressionistruethe library owns capture and feeds the mixer viaappend; whenfalseit reverts to HaishinKit's own mic. Apple's processor is voice-isolation, so it suppresses non-voice background (incl. music) more than Android does.
| | noiseSuppression={false} | noiseSuppression={true} |
|---|---|---|
| Voice | Natural, full dynamic range | Natural — no AGC pumping (both platforms) |
| Steady fan / AC | Audible | Suppressed |
| Music in background | Preserved | Android: preserved · iOS: suppressed (voice-isolation) |
| Phone-call feel | No | No |
Both platforms behave identically.
Toggleable live mid-session via the prop — the change applies immediately on both platforms (Android swaps the spectral effect; iOS re-runs setCategory). No resetAudioEncoder() call needed.
Stereo capture
prepareAudio(..., isStereo) controls AAC channel count. On Android, the library silently downgrades stereo → mono on known single-mic / fake-stereo devices.
This matters because budget Android phones (Realme across the board, budget Oppo A-series, OnePlus Nord N-series, Redmi 12 / A-series, anything on UNISOC or low-end MediaTek) ship a single physical mic capsule. When an app asks for stereo, the audio HAL synthesises a second channel by duplicating the mono signal. That doubling has two downstream costs:
- Doubled
AudioRecordPCM throughput per callback. - ~1.6× AAC software-encoder CPU per frame (joint-stereo is cheaper than 2× independent encoding, but not free).
On chips with weak performance cores (e.g. UNISOC Tiger T612) shared with the camera HAL, this is enough to push the audio thread past its ~21 ms deadline, causing dropped AAC frames and audibly chunky / stuttering playback.
How the downgrade decides
In HybridRtmpPublisherView+Encoders.kt, resolveEffectiveStereo() runs three checks in order and forces mono on the first match:
FAKE_STEREO_BUILTIN_MIC_BRANDS— universally-budget brands where every shipped device has the bug. Currently justrealme(its entire lineup is single-mic).oppo/oneplus/redmiare deliberately not in this set — their flagships (Find X, OnePlus 8+, Redmi Note Pro / K) ship real dual-mic arrays and forcing mono there would lose real capability.KNOWN_SINGLE_MIC_MODELS— exact-matchBuild.MODELcodes for budget SKUs from mixed-lineup brands. Empty by default; populate as field reports come in. NoteBuild.MODELreturns SKU codes, not consumer names — OnePlus Nord N100 isBE2013, notNord N100.builtInMicCount() < 2— fallback for OEMs that correctly expose only oneTYPE_BUILTIN_MICentry (some single-mic devices that don't lie in theiraudio_policyconfig).
If none match, the caller's isStereo is honoured. The decision inputs (brand, manufacturer, model, mic count, which check fired) are logged at Log.INFO with tag RtmpPublisherView — adb logcat -s RtmpPublisherView:I shows the line.
Contributing a new device
Got chunky audio on a device that's not caught? Grab Build.MODEL from adb shell getprop ro.product.model while the app is running, then add it to KNOWN_SINGLE_MIC_MODELS in HybridRtmpPublisherView+Encoders.kt and open a PR. One-line change.
No-op on iOS — every iPhone has two physical mics and the audio session handles channel routing.
Thermals
Sustained streaming can throttle the SoC. The library hooks the OS thermal API on both platforms so you can react without polling.
// Set the trip level via the `thermalWarningThreshold` prop (default 'severe').
// The OS listener is registered when you subscribe (and, on iOS, also while the
// beauty filter is on so it can auto-throttle) — zero work otherwise.
ref.setOnThermalWarning((status) => {
// 'none' | 'light' | 'moderate' | 'severe' | 'critical' | 'emergency' | 'shutdown'
if (status === 'severe') ref.setVideoBitrateOnFly(1_000_000)
if (status === 'critical') ref.stopStream()
})
// One-shot read
const now = ref.getThermalStatus()The callback fires twice for a typical heat event:
- When the state first crosses the threshold (entering the warning zone)
- Once when it falls back below the threshold (clearing — useful for "all good, dial back up" logic)
- Android: requires API 29 (Android 10). Older devices always report
'none'. - iOS: maps
ProcessInfo.thermalState(nominal/fair/serious/critical) onto our 7-level scale. - Beauty-filter auto-throttle: when the beauty filter is on, both platforms automatically lighten it under thermal pressure (Android drops
highp→mediump; iOS reduces smoothing intensity + blur radius), restoring on cooldown. This runs independently ofsetOnThermalWarning— it kicks in even if you never subscribe.
Testing thermal handling without overheating the device
Android:
adb shell cmd thermalservice override-status 3 # SEVERE
adb shell cmd thermalservice override-status 0 # back to normal
adb shell cmd thermalservice reset # release overrideiOS: thermal state can't be triggered from the CLI; use Instruments' Thermal State template, or wrap the device in a thermal cover and run an encoder benchmark.
Events
// Connection state changes (low frequency, important).
ref.setOnConnectionEvent((event, message) => {
// event ∈ 'connectionStarted' | 'connectionSuccess' | 'connectionFailed'
// | 'disconnect' | 'reconnecting' | 'authError' | 'authSuccess'
})
// Per-second bitrate updates (OPT-IN). If you never call this,
// nothing crosses the JSI bridge for bitrate.
ref.setOnBitrateChange((bitrateBps) => { ... })
// Local recorder state (OPT-IN).
ref.setOnRecordStatusChange((status) => { ... })iOS extras — backgrounding the app or having another app grab the camera (incoming call, Siri, Control Center) emits a synthetic
disconnectevent with a descriptive reason like"session interrupted: video-device-in-use-by-another-client". When the camera comes back, you receive aconnectionSuccess-equivalent fresh state. The UI never has to poll.
Picture-in-Picture
System Picture-in-Picture — the OS floating window with the camera preview inside. The JS API is identical on both platforms, but the runtime behavior differs because iOS restricts camera capture in the background:
| Capability | Android | iOS |
|---|---|---|
| PIP available | every device | iPhone iOS 18+ (host declares voip) / M1+ iPad only — disabled on older iOS / pre-M1 iPad |
| Camera live in PIP + stream keeps publishing | yes, every device (via the foreground service) | yes — it's the only tier offered (camera stays live, stream keeps publishing) |
API (same on both platforms):
| Method | Notes |
|---|---|
| enterPictureInPicture(): boolean | Request PIP now. Use for a manual button (and the Android 8–11 path). Returns false if PIP is unsupported, disabled for the app in system settings, there's no host Activity, or it's already active. (iOS may defer the actual start until the window is ready and still return true.) |
| isInPictureInPicture(): boolean | true while in the PIP window. |
| setOnPictureInPictureChange(cb: (isInPip: boolean) => void): void | Fires true on enter, false on exit (delivered on the JS thread). Use it to hide overlays/controls while in PIP. |
Set pictureInPictureEnabled to arm auto-enter on background (Android API 31+ via setAutoEnterEnabled; iOS via canStartPictureInPictureAutomaticallyFromInline). The floating window uses the device's screen aspect ratio.
Android setup
Manifest — the host activity must declare PIP support and absorb the configuration changes PIP fires (otherwise Android recreates the Activity and the camera restarts):
<activity
android:name=".MainActivity"
android:supportsPictureInPicture="true"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode" />Expo: there's no first-class
app.jsonfield forsupportsPictureInPicture/configChanges, and a plainexpo prebuildwould drop a hand-edited manifest — so the library's config plugin does it for you. Enable it once:{ "expo": { "plugins": [ ["react-native-nitro-rtmp-publisher", { "enablePictureInPicture": true }] ] } }The next
expo prebuildaddssupportsPictureInPicture="true"and merges the requiredconfigChangesinto your launcher activity (existing entries are preserved). Bare RN apps edit the manifest directly as shown above.
enablePictureInPictureis a common key — the same flag also arms iOS PIP (see iOS setup). For Android only, scope it:{ "android": { "enablePictureInPicture": true } }.
iOS setup
iOS PIP is built on AVPictureInPictureController + an AVSampleBufferDisplayLayer (HaishinKit's PiPHKView) fed the composited preview frames — the only sanctioned path for live, non-AVPlayer content. It works on iOS 15+ with no extra setup beyond the audio background mode you already need for background streaming.
PIP is offered only on the live tier — devices where iOS keeps the camera running during multitasking. By design it is disabled elsewhere: iOS suspends the camera the moment the app backgrounds (entering PIP is backgrounding), so a non-live device could only show a frozen frame with a paused stream — a worse experience than no PIP. The library detects this at runtime (AVCaptureSession.isMultitaskingCameraAccessSupported) and arms PIP only where it's live.
Disabled (no-op): iPhone < iOS 18, any iPhone that doesn't declare the
voipbackground mode, and pre-M1 iPads.pictureInPictureEnableddoes nothing andenterPictureInPicture()returnsfalse. (Android PIP is unaffected.)Enabled (live): iPhone iOS 18+ with
voipinUIBackgroundModes, and M1+ iPads — the camera stays live and the RTMP stream keeps publishing in the floating window. Opt in by adding thevoipbackground mode:// app.json → expo.ios.infoPlist "UIBackgroundModes": ["audio", "voip"]Expo: rather than editing
infoPlistby hand, enable the plugin option — it mergesvoip+audiointoUIBackgroundModesat prebuild time (parity with the Android option, and any modes you already declare are preserved):{ "expo": { "plugins": [ ["react-native-nitro-rtmp-publisher", { "enablePictureInPicture": true }] ] } }This is the same common
enablePictureInPicturekey shown in Android setup — one flag arms PIP on both platforms. For iOS only, scope it:{ "ios": { "enablePictureInPicture": true } }.The library then enables
AVCaptureSession.isMultitaskingCameraAccessEnabledautomatically where supported — no entitlement needed on iOS 18+.⚠️ App Store note:
voipis intended for VoIP / video-conferencing apps; declaring it on a one-way broadcaster carries some Guideline 2.5.4 review scrutiny. If you don't addvoip(manually or via the plugin option), iOS PIP simply stays disabled — no review risk, and Android PIP still works everywhere.
Keep the keyboard off the streaming screen. A focused
TextInputunder aKeyboardAvoidingView(behavior="height") can mis-size the preview while in PIP (it sizes from screen metrics, not the small PIP window). Edit text such as the RTMP URL in a separate modal so its keyboard lives in its own window — seeexample/src/components/UrlModal.tsxand example/App.tsx.
<RtmpPublisherView ... pictureInPictureEnabled={true} hybridRef={hybridRef} />
// hide your overlays while the floating window is showing
ref.setOnPictureInPictureChange((inPip) => setControlsVisible(!inPip))
// optional manual trigger (also the Android 8–11 path)
ref.enterPictureInPicture()Platform parity
Same JS API, same behavior — but worth knowing exactly where the platforms differ under the hood:
| Feature | iOS | Android |
|---|---|---|
| Capture | AVFoundation | Camera2 |
| Encoder | VideoToolbox | MediaCodec |
| Preview | HaishinKit MTHKView (Metal) | OpenGlView (OpenGL ES) |
| RTMP transport | HaishinKit | RootEncoder |
| Video codecs | H.264, HEVC | H.264, HEVC, AV1 |
| Audio codec | AAC only (RTMP) | AAC (G.711, Opus accepted but RTMP-incompatible) |
| Adaptive bitrate signal | TX throughput delta | RTMP send-buffer depth |
| forceIncrementalTs | Intrinsic (no-op flag) | Active knob |
| setStreamDelay | No-op | Active knob |
| Background streaming | UIBackgroundModes: ['audio'] | Foreground service via foregroundServiceTitle |
| Wake lock | UIApplication.isIdleTimerDisabled | PARTIAL_WAKE_LOCK |
| Mirror | Single AVCaptureConnection buffer; UIView transform for asymmetric cases | Separate preview / stream flip flags, re-applied on switchCamera() |
| noiseSuppression | Apple Voice Processing (NS+AEC, AGC disabled) on an owned AVAudioEngine capture → mixer.append (HaishinKit has no audio-effect hook); voice-isolation, so it also cuts music | Custom spectral denoiser (decision-directed Wiener + noise-floor tracking) on the RootEncoder mic PCM tap; keeps music |
| Beauty filter | CoreImage VideoEffect (frequency-separation skin-smoothing) on the HaishinKit mixer; auto-throttles intensity + blur under thermal pressure | RootEncoder BeautyFilterRender; auto highp/mediump by device tier + thermal downgrade |
| Picture-in-Picture | AVPictureInPictureController + AVSampleBufferDisplayLayer (HaishinKit PiPHKView); offered only on the live tier — iPhone iOS 18+ (voip mode) / M1 iPad, where camera + stream stay live — and disabled (no-op) on older devices (no frozen-frame fallback) | Device-aspect PictureInPictureParams + addOnPictureInPictureModeChangedListener, setAutoEnterEnabled on API 31+; preview + RTMP stream stay live on every device |
When in doubt, the JS API is the contract. Where a knob doesn't map to one platform, we either translate (audioSource modes) or silently no-op (forceHardwareCodec on iOS).
Performance notes
Everything in the hot path stays native:
- Capture → GPU compose → encoder input surface — never crosses into JS, no per-frame allocation.
- HW encode — VideoToolbox / MediaCodec on a dedicated thread. Effectively zero CPU.
- FLV muxing + RTMP TX — HaishinKit's
RTMPClient/ RootEncoder'sRtmpClienton background threads. - JS bridge — touched only on:
setOnConnectionEventtransitions (rare),setOnBitrateChange(opt-in, ~1 Hz when subscribed),- any imperative method you call.
Knobs that move CPU/GPU usage:
| Lever | Effect |
|---|---|
| Resolution (1080p → 720p) | ~2× less work end-to-end |
| FPS (30 → 24) | -20% |
| forceHardwareCodec={true} | Required for "free" encode on Android |
| Don't subscribe to bitrate | Saves one bridge round-trip per second |
| Don't record while streaming | Avoids a second encoder |
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ JavaScript │
│ <RtmpPublisherView │
│ videoCodec mirrorPreview … hybridRef={...}/> │
│ ▲ │
│ │ generated by Nitrogen │
│ ▼ │
│ ref.prepareVideo() / startStream() / setZoom() / … │
└─────────────────────────────────────────────────────────────────┘
│ JSI (one call, no JSON)
▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ HybridRtmpPublisherView.kt │ │ HybridRtmpPublisherView.swift│
│ (Android) │ │ (iOS) │
│ │ │ │
│ OpenGlView ── RtmpCamera2 │ │ MTHKView ── RTMPStream │
│ │ │ │ │ │ │ │
│ ▼ ▼ │ │ ▼ ▼ │
│ GL preview MediaCodec │ │ Metal preview VideoToolbox │
│ ↓ RTMP/TCP │ │ ↓ RTMP/TCP │
└──────────────────────────────┘ └──────────────────────────────┘
│ │
▼ ▼
RootEncoder HaishinKit- JS surface — typed spec at
src/specs/RtmpPublisher.nitro.ts. Nitrogen generates the C++ shim, the platform-native base class, and the Fabric view component config. - Native implementations —
HybridRtmpPublisherViewin Kotlin (Android) and Swift (iOS); each extends the generated spec. - Lifecycle — both platforms emit a synthetic
disconnectevent when the camera is yanked out from under us (rotation, backgrounding, phone call, another app grabbing the camera), so the JS state never silently diverges from reality.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Camera opens/closes in a loop | hybridRef callback recreated per render | Wrap in useMemo([], …) |
| Output stream lands in landscape after a few seconds | prepareVideo called after startPreview; encoder rotation latched late | Call prepareVideo + prepareAudio before startPreview |
| Output is upside down | Rotation applied twice | Pick one source of truth: either pass rotation to prepareVideo OR drive setStreamRotation from an orientation observer, not both |
| Audio missing from stream (Android) | prepareAudio ran without RECORD_AUDIO permission granted | Call requestRtmpPermissions() and only mount the view after granted === true |
| White screen on real iOS device with embedded JS bundle | Hermes inspector tries to open devtools WebSocket in __DEV__ mode | Build with --dev false for the embedded bundle, or use Metro over Wi-Fi |
| Unable to resolve module ./index from Metro | The package symlink resolves recursively into example/ | Use metro.config.js with a blockList regex for the example path |
| iOS: stream is sideways with the camera vertical | iOS encoder sized for landscape but receiving portrait frames | The library swaps dimensions automatically when rotation: 0 is passed — make sure you're using the JS API surface, not patching the Swift side |
Contributing
PRs welcome. The library structure:
src/specs/ # Nitro spec (single source of truth — drives codegen)
src/permissions.ts # Cross-platform permission helper
ios/ # Swift implementation
android/src/ # Kotlin implementation
nitrogen/generated # COMMITTED — regenerated via `npm run specs`
example/ # Reference app (Expo SDK 56 / RN 0.85)To work on the library locally:
npm install
cd example
npm install
# Android
npm run android
# iOS
npm run ios:device -- <UDID>After changing the spec, run npm run specs from the repo root to regenerate the Nitrogen bindings, then cd example/ios && pod install to pick up new sources on iOS.
FAQ
Does this support RTMPS for Facebook Live and Instagram Live?
Yes. RTMPS (RTMP over TLS) works out of the box — Facebook Live's rtmps://…fbcdn.net:443/rtmp/… URLs and Instagram Live's signed rtmps:// ingest URLs both connect and publish without extra configuration. Just pass the URL to startStream.
Can I stream to YouTube Live, Twitch, or any custom RTMP server?
Yes. The library is server-agnostic. Anything that speaks RTMP / RTMPS works: YouTube Live (rtmp://a.rtmp.youtube.com/live2), Twitch (rtmp://live.twitch.tv/app), Wowza, Nimble, Ant Media Server, Red5, MediaMTX, or your own Nginx-RTMP / Node-Media-Server. FMLE-style handshake (releaseStream + FCPublish) is sent on connect so strict ingests (FB Live, YouTube Live, Wowza) accept the publish without timing out.
Does it work with Expo and EAS Build?
Yes — managed and bare both. Add "react-native-nitro-rtmp-publisher" to your app.json plugins list and re-run expo prebuild; the bundled Expo config plugin idempotently injects the vendored HaishinKit pods into your generated Podfile. No need to eject. See Install → iOS — Expo (one-liner).
Does this support the React Native New Architecture (Fabric)?
Yes. Nitro Modules require the New Architecture — Fabric and TurboModules must be enabled. The view is a Fabric component end-to-end; legacy Paper is not supported.
Can I record locally to MP4 while streaming live?
Yes. Call startRecord(path) while streaming and the library writes an MP4 to disk in parallel with the RTMP push — same encoded frames feed both sinks, so there's no second encode tax on the CPU. See Local recording.
Does streaming continue when the app goes to the background?
On iOS, audio continues if you declare UIBackgroundModes: ["audio"]; the video pipeline pauses (iOS suspends AVCaptureSession on background) and resumes when you foreground. The library auto-reconnects within ~1.5 s of foregrounding. On Android, a foreground service keeps the encoder and RTMP socket alive.
What's the minimum iOS and Android version?
iOS 15+ (HaishinKit 2.2.x requirement) and Android minSdkVersion 21. For iOS 13 / 14, pin to v0.3.x of this package (HaishinKit 1.9.x).
How does this compare to other React Native RTMP libraries?
Older packages like react-native-rtmp-publisher, react-native-camera-rtmp, or react-native-live-stream predate the New Architecture and use the legacy bridge — every getter, every callback, every prop write crosses a JSON-serialized boundary. react-native-nitro-rtmp-publisher uses Nitro's JSI bridge: callbacks fire as direct C++ calls, view props apply on the C++ shadow thread, and the per-frame camera pipeline never touches JS at all. You also get first-class Expo support, HEVC encoding, RTMPS, and adaptive bitrate out of the box.
What video and audio codecs are supported?
Video: H.264 (AVC) and H.265 (HEVC) — both hardware-encoded via VideoToolbox (iOS) and MediaCodec (Android). Audio: AAC-LC at 44.1 / 48 kHz, mono or stereo.
Acknowledgements
- Nitro Modules by Marc Rousavy — the JSI codegen that makes the typed bridge possible
- RootEncoder by Pedro Sánchez — Android RTMP runtime
- HaishinKit.swift by Shogo Endo — iOS RTMP runtime
License
MIT (this package). RootEncoder is Apache-2.0. HaishinKit.swift is BSD-3-Clause.
