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) // captureToTextureThe 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.videoQuality | captureWidth×Height@fps | lockResolution | Degradation |
|---|---|---|---|
| Auto | 960×540@30 | false | WebRTC BALANCED - drops resolution, holds FPS |
| Data Saver | 640×480@30 | true | Hold resolution, drop FPS |
| HD | 1280×720@30 | true | ” |
| Full HD | 1920×1080@30 | true | ” |
| 2K | 2560×1440@30 | true | ” |
| 4K | 3840×2160@30 | true | ” |
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. OtherwisestartCapture(w, h, fps).pauseCapture()-stopCapture()when the app isn’t visible; no-op while screen sharing. Called from a delayedpauseCameraRunnableinonStop(250 ms) thatonStartcancels - PiP close bouncesonStop→onStartin ~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 >= 2→ensureCapturing()uses the fallback format (640×480@30), which every device supports.- The health gate.
onFirstFrameAvailabledoes 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. InsteadonCaptureHealthy()arms acaptureHealthyRunnablethat clears the counter only if the capture is still alive 4 seconds later;onCaptureLostcancels 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 aSCALER_CROP_REGIONon 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:
SurfaceViewRendererfor the full-bleed remote video and the self-view - best performance, hardware overlay.TextureViewpath 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.