Skip to Content
InternalsVideo capture

Video capture

Camera selection

createCameraCapturer() tries Camera2 first, falls back to Camera1:

if (Camera2Enumerator.isSupported(context)) { createFromEnumerator(Camera2Enumerator(context), eventsHandler)?.let { return it } } return createFromEnumerator(Camera1Enumerator(true), eventsHandler) // captureToTexture

The default facing is AppSettings.defaultCameraFront (front). switchCamera() and setCameraFacing(front, cb) flip; the front camera is mirrored in the self-view (and only the self-view) when mirrorFrontCamera is set.

Camera source, not screencast

videoSource = pcf.createVideoSource(/* isScreencast = */ false)

This is deliberate. A non-screencast source tells WebRTC to degrade under CPU/bandwidth pressure by dropping resolution and holding framerate (BALANCED). A screencast source does the opposite - it pins resolution and lets FPS collapse, which is why an early 4K build ran at ~6 fps. Screen sharing uses a separate screencast source precisely because there the opposite trade-off is correct.

Resolution: preset vs. Auto

applySourceOutputFormat() calls videoSource.adaptOutputFormat(long, short, fps) with the current target.

AppSettings.videoQualitycaptureWidth×Height@fpslockResolutionDegradation
Auto960×540@30falseWebRTC BALANCED - drops resolution, holds FPS
Data Saver640×480@30trueHold resolution, drop FPS
HD1280×720@30true
Full HD1920×1080@30true
2K2560×1440@30true
4K3840×2160@30true

setVideoQuality(w, h, fps, maxKbps, lockRes) changes all of it at once: adaptOutputFormat, the encoder maxBitrateBps, and encoding.maxFramerate (set to captureFps when locked, null when Auto). If the format actually changed it stopCapture()ensureCapturing() and re-applies zoom after 400 ms.

CameraCapabilities - hiding formats the device can’t produce

CameraCapabilities.maxCaptureHeight(context) walks every camera’s getSupportedFormats() and returns the tallest (portrait-normalised) height. SettingsScreen uses it to hide presets the hardware can’t reach.

Known imprecision

It takes the max across front + back cameras. A Pixel’s rear camera does 4K but its selfie camera does not, so the 4K preset is offered even though a front-camera call can’t fulfil it - the recovery loop then handles the fallback. A per-facing capability check is the correct fix.

Recovery

Android yanks the camera constantly - backgrounding, another app opening it, PiP closing, screen lock. The recovery model:

Key fields and rules:

  • ensureCapturing() - no-op while screen sharing, already capturing, or the user turned the camera off. Otherwise startCapture(w, h, fps).
  • pauseCapture() - stopCapture() when the app isn’t visible; no-op while screen sharing. Called from a delayed pauseCameraRunnable in onStop (250 ms) that onStart cancels - PiP close bounces onStoponStart in ~90 ms, and reacting immediately would trigger a ~0.6 s Camera2 recreate (a visible freeze).
  • onCaptureLost() - isCapturing = false, consecutiveCaptureFailures++, schedule one retry in 900 ms.
  • consecutiveCaptureFailures >= 2ensureCapturing() uses the fallback format (640×480@30), which every device supports.
  • The health gate. onFirstFrameAvailable does not immediately reset the failure counter. A device that can’t sustain the requested format still emits one frame before the HAL tears the session down - resetting on that frame meant the counter never reached 2 and the app looped on the bad format forever. Instead onCaptureHealthy() arms a captureHealthyRunnable that clears the counter only if the capture is still alive 4 seconds later; onCaptureLost cancels it.

This is what makes a device whose front camera can’t do 4K settle at 640×480 within two retry cycles instead of pegging a thread reopening a doomed 4K session every second.

Zoom (CameraZoomController.kt)

Pinch-to-zoom on the local video. setZoom(ratio):

  • Camera2 path - sets CONTROL_ZOOM_RATIO (API 30+) or a SCALER_CROP_REGION on the capture request via the capturer’s session.
  • Fallback - a digital crop applied through the VideoSource.

The controller re-applies the current zoom after any capture restart (stopCapture/startCapture, quality change) because a new session resets to 1×.

Rendering (VideoRenderer.kt, TextureViewRenderer.kt)

Compose wraps WebRTC’s Java renderers:

  • SurfaceViewRenderer for the full-bleed remote video and the self-view - best performance, hardware overlay.
  • TextureView path available for cases where z-ordering matters (a grid, or the PiP self-view over video).

onRemoteOrientation fires straight off decoded frames via a dedicated remoteOrientationSink (frame.rotatedWidth > frame.rotatedHeight) - independent of whether a renderer is attached, so a portrait↔landscape flip during a surface teardown isn’t missed the way onFrameResolutionChanged can be.