Skip to content

Video calls ​

A video call in Callx is a normal call that is also reported to the system as video. The call rings, is answered and connects audio exactly as a voice call does; the camera and the video views come on top, through the same native connection the media adapter already owns.

Status

Video and picture-in-picture are available on Android, and iOS picture-in-picture is experimental. Flutter Android video has passed emulator conformance; Flutter and RN PiP have Android 16 UI trials covering video, camera continuity and branded fallback. Physical devices and iOS video remain unverified. See the status page.

What changes and what does not ​

  • The call states do not change. active still means audio is usable. A video call whose video has not arrived yet is active.
  • Video is part of the call. The call carries four fields: video (offered or started as video), localVideo (off, on, or blocked when the OS took the camera), cameraFacing (while the camera is not off) and remoteVideo (the other side's video can be shown).
  • The camera is a command with a result, like mute: setCamera and switchCamera.
  • Audio first, video when the app is open. A video call answered on the lock screen or from the notification connects audio at once. The camera can only start once your app is in front, because iOS and Android do not allow the camera from the background.

Requirements ​

  • A media adapter that carries video. The LiveKit adapter implements media adapter API 2. Keep the core and adapter packages on the same version; check capabilities.video after setup().
  • The camera permission:
    • Expo: add video: true and, if you like, cameraPermission to Callx's plugin. It adds NSCameraUsageDescription, Android's CAMERA permission and an optional camera feature, so phones without a camera can still install the app.
    • React Native CLI and Flutter: add NSCameraUsageDescription to Info.plist, and android.permission.CAMERA with <uses-feature android:name="android.hardware.camera" android:required="false" /> to AndroidManifest.xml.
  • Ask for the camera permission yourself, when the user turns the camera on. Callx does not ask; without it, setCamera(true) is rejected with permissionDenied.

Ring as a video call ​

Add "video": true to the invitation your backend sends (backend guide):

json
{"schemaVersion": 1, "type": "call.invited", "callId": "85a4fd88-…", "displayName": "Alex",
 "handle": "callx:user-a", "expiresAtMs": 1790000030000, "video": true}

The call rings with the system's video call UI: CallKit shows it as a video call, and Core-Telecom registers it as CALL_TYPE_VIDEO_CALL. To start one, pass video: true to startCall.

ts
await callx.startCall({callId, displayName: 'Alex', handle: 'callx:user-a', video: true});
dart
await callx.startCall(
  CallInput(callId: callId, displayName: 'Alex', handle: 'callx:user-a', video: true),
);

Show the video ​

One view shows one source. Use two for the usual layout: the other side full screen and your own camera in a corner.

tsx
import {CallxVideoView} from '@bear-block/callx/video';

<View style={{flex: 1}}>
  {call.remoteVideo && <CallxVideoView callId={call.callId} style={{flex: 1}} />}
  {call.localVideo === 'on' && (
    <CallxVideoView callId={call.callId} source="local" mirror={call.cameraFacing === 'front'}
      style={{position: 'absolute', right: 16, bottom: 16, width: 96, height: 128}} />
  )}
</View>
dart
Stack(children: [
  if (call.remoteVideo) Positioned.fill(child: CallxVideoView(callId: call.callId)),
  if (call.localVideo == LocalVideo.on)
    Positioned(
      right: 16, bottom: 16, width: 96, height: 128,
      child: CallxVideoView(
        callId: call.callId,
        source: VideoSource.local,
        mirror: call.cameraFacing == CameraFacing.front,
      ),
    ),
])

The view is empty until its source exists. Two views may show the same source.

Turn the camera on ​

ts
const result = await callx.setCamera(call.callId, true);
if (result.status !== 'applied') {
  // permissionDenied, mediaNotReady (app in the background), unsupported (no video adapter)
}
await callx.switchCamera(call.callId, 'back');
dart
final result = await callx.setCamera(call.callId, true);
await callx.switchCamera(call.callId, CameraFacing.back);
  • setCamera(true) is applied once the adapter publishes the camera, and the call's localVideo becomes on.
  • switchCamera while the camera is off only remembers the choice for the next setCamera(true).
  • An audio call can turn the camera on too. Telling the other side is your signaling's job, as for every other change.
ResultWhen
appliedThe camera is on, off or switched
rejected / permissionDeniedNo camera permission
rejected / mediaNotReadyThe app is not in front, or media has not connected yet
rejected / unsupportedNo video adapter is installed
rejected / invalidStateThe call is ringing or has ended

Background and the lock screen ​

Verification gap

Video-call acceptance with a secure PIN lock has passed on Android 16 (API 36) for the Flutter debug and React Native release examples. Trials cover FCM delivery while the screen is off, native answer/decline, audio connection before unlock, video after foreground, and camera pause/resume when locking an active call. Other Android versions, iOS and physical devices still need verification. See the status page.

Outside a supported PiP session, when your app goes to the background with the camera on, the OS stops the camera and the call shows localVideo: 'blocked'. The call itself continues with audio. When the app comes back, the adapter resumes the camera and localVideo returns to on. Turning the camera off from blocked is a normal setCamera(false).

Picture in picture on Android ​

Enable PiP on the host activity with android:supportsPictureInPicture="true" and android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation", preserving any existing configuration flags. With Expo, use pictureInPicture: true in the Callx plugin.

ts
import {configurePictureInPicture, enterPictureInPicture,
  addPictureInPictureListener} from '@bear-block/callx/video';

configurePictureInPicture({automatic: true});
const stop = addPictureInPictureListener(inPiP => {
  // Render only the remote video (or your local preview) while inPiP is true.
});
await enterPictureInPicture(); // false if the activity or device cannot enter PiP
// On screen cleanup: stop(); configurePictureInPicture({automatic: false});
dart
await CallxPictureInPicture.configure(automatic: true);
final subscription = CallxPictureInPicture.changes.listen((inPiP) {
  // Render only the remote video (or your local preview) while inPiP is true.
});
await CallxPictureInPicture.enter();
// On screen cleanup: subscription.cancel();
// CallxPictureInPicture.configure(automatic: false);

The whole activity shrinks into the PiP window. Keep its layout compact even if the call ends while the window is open. Automatic entry is available on Android 12 and later, and only while an answered video call is live. On Android 10 and 11, use the explicit entry button. The camera continues while the activity is visible in PiP; when the activity stops, the adapter blocks it as described above. PiP is separate from the call contract.

A branded fallback ​

The examples prefer remote video, then the local camera. When neither source is available, render your app's background colour and logo. This also works for a connected call before the other side publishes video, or after the call ends while PiP is still open.

Android PiP displays your activity, so branding belongs to your Flutter/RN layout. No extra native PiP configuration is needed. Use your app's theme and image asset, or expose backgroundColor and logo props if you build a reusable call-screen component.

Both examples use an app-owned CallBrand in their separate call-screen component. The remote video fills the call screen, the local preview sits at the top right, and diagnostics have their own screen. Use those components as a starting point and replace the colors/logo with your own app's branding.

tsx
// appLogo is your image source; brandColor comes from your theme.
<View style={{flex: 1, backgroundColor: brandColor,
  alignItems: 'center', justifyContent: 'center'}}>
  <Image source={appLogo} resizeMode="contain" style={{width: 64, height: 64}} />
</View>
dart
// appLogo is your logo widget or image asset.
ColoredBox(
  color: Theme.of(context).colorScheme.primary,
  child: SizedBox.expand(child: Center(child: appLogo)),
)

Picture in picture on iOS ​

Experimental on iOS

iOS 15+ supports video-call PiP with AVKit. It passes native lifecycle tests and Simulator builds, but has not been accepted on a physical iPhone yet: camera continuity, restoring the app and two-party media are unverified. See status.

Use the same configurePictureInPicture, enterPictureInPicture and listener APIs in React Native, or CallxPictureInPicture in Flutter. Keep an inline CallxVideoView mounted, with nonzero bounds in the visible window. Enable audio in UIBackgroundModes; the Expo plugin already configures it. Automatic entry is opt-in and is prepared only for an answered live video call. Unsupported devices or a missing inline source return false on manual entry. A true result means the start request was submitted; the listener confirms actual entry.

Unlike Android, iOS presents a separate native video-call window, rather than shrinking the entire Flutter/RN screen. The core attaches a PiP surface to the existing video adapter: remote video first, then an available local camera, then app branding. LiveKit uses its sample-buffer renderer for that surface; the inline renderer keeps its existing mode. CallxBootstrap and the framework native host configuration bind PiP to the runtime automatically. Manual Swift hosts can use await CallxPictureInPicture.shared.bind(to: runtime) and bind nil on teardown; runtime replacement discards pending callbacks from the old binding. Ending the call clears the content source and detaches the PiP renderer even without a Dart/TypeScript observation session. Restoring the app reports false to the framework listener and waits for a mounted inline view before acknowledging the OS restore request.

Native hosts can customize fallback branding after bootstrap:

swift
CallxPictureInPicture.shared.setFallback(
    backgroundColor: UIColor(red: 0.1, green: 0.2, blue: 0.4, alpha: 1),
    image: UIImage(named: "CallLogo"),
    text: "My app"
)

The image takes precedence over text. This native fallback is independent of Flutter/RN compact-layout branding. AVKit's video-call PiP window does not accept custom button taps. Native hosts with their own navigation can provide restoreUserInterface and acknowledge its completion once their call view is visible.

Camera continuity is conditional. LiveKit enables multitasking capture only when CameraCapturer.isMultitaskingAccessSupported permits it. It keeps the camera unmuted in the background only while this call's PiP is starting/active and multitasking access is enabled. Otherwise it pauses publication and reports localVideo: 'blocked'; foregrounding resumes it. OS capture interruptions, including another app taking the camera or stashing PiP, also report the blocked state. Starting a new camera publication still requires the foreground.

Apple requires the multitasking-camera entitlement for apps whose deployment target is below iOS 16. Callx retains its iOS 15 minimum and does not silently add this entitlement; configure the host's signing capabilities as appropriate and check actual device support. See Apple's video-call PiP guidance and the Android PiP guide.

Platform notes ​

  • iOS: CallKit's hasVideo follows the camera, so the system UI matches. Callx enables supportsVideo on its CallKit provider when the adapter carries video.
  • Android: from Android 14 (API 34), Telecom registers the call as a video call. Below that, Core-Telecom adds calls through a ConnectionService, which Telecom records as audio; the video itself is unaffected, only what Telecom reports to the system (for example to a car). Core-Telecom 1.0 also cannot change a call's type after it started, so a call stays the type it was offered or started as. In Flutter, video views use Texture Layer Hybrid Composition, which needs adapters to render with a TextureView.
  • Preview: the simulator supports video: simulator.remoteVideo(true) and simulator.cameraBlocked(true).

Writing a video adapter ​

A video adapter implements CallxVideoAdapter (adapter API 2): setCamera, plus attach and detach to render into the surfaces the views give it. See write a media adapter.

Released under the MIT License. No telemetry, in the library or on this site.