Skip to Content
InternalsPicture-in-Picture

Picture-in-Picture

Entering PiP

MainActivity overrides onUserLeaveHint() - fired when the user navigates away (Home, Recents, another app) but not when the screen just turns off:

override fun onUserLeaveHint() { super.onUserLeaveHint() if (inCallState) enterPipMode() } fun enterPipMode(): Boolean { if (SDK_INT >= O && inCallState && !isScreenSharingState) { return enterPictureInPictureMode(buildPipParams()) } return false }

PiP is not used while screen sharing.

PiP params

PictureInPictureParams.Builder() .setAspectRatio(pipAspectRatio()) // 9:16 portrait, or 16:9 if the peer is sharing landscape .setActions(listOf(micAction, cameraAction)) .build()
  • Aspect ratio follows the remote content. pipAspectRatio() returns 16:9 only when the peer is screen-sharing and their content is landscape (derived from remoteContentRotation / remoteContentLandscape), otherwise 9:16.
  • Actions are RemoteActions backed by a PendingIntent.getBroadcast. A BroadcastReceiver registered with RECEIVER_NOT_EXPORTED handles the taps: pipControlMic → toggle mic, pipControlCamera → toggle camera, pipControlStopShare → stop share, pipControlEnd → leave.
  • The icons track state - updatePipParamsIfActive() re-publishes the params (mic on/off icon, camera on/off icon) after every toggle.

The PiP layout

CallScreen has a dedicated early-return branch:

if (isPipMode) { Box(Modifier.fillMaxSize().background(CallColors.VideoBackdrop)) { if (!peerLeft) WebRtcVideoRenderer(remote, fitContent = remoteScreenSharing, …) if (peerLeft || (connected && !remoteVideoEnabled && !remoteScreenSharing)) { Box(background = CallColors.Surface) { Avatar(remoteAvatar, remoteUserName) } } } return }

The PiP window shows remote video only - no self-view, no controls (those live in the system PiP action bar), no chrome. A screen share is fitContent (letterboxed, not cropped) so the whole shared screen is visible in the small window.

singleTask re-entry

MainActivity is android:launchMode="singleTask". Without it, tapping the launcher icon while the call is in PiP creates a second MainActivity instance stacked on the PiP’d one (confirmed via dumpsys - two ActivityRecords in the task). singleTask routes the launch to the existing instance, which expands PiP back to fullscreen.

Lifecycle bounce

Closing a PiP window bounces window visibility: onStop then onStart within ~90 ms. Reacting to onStop immediately would pauseCapture(), then onStart would restart it - a ~0.6 s Camera2 recreate that reads as a freeze. So the camera pause is a postDelayed(pauseCameraRunnable, 250) that onStart cancels. A real background (lock, Home, PiP fully closed) has no bounce, so the pause lands ~250 ms later - imperceptible.

Theme

The call UI has its own palette, CallColorScheme, separate from the app’s Material 3 scheme:

@Immutable data class CallColorScheme( val isLight: Boolean, // lets canvas code pick a light-appropriate path val VideoBackdrop: Color, // behind video + letterbox - ALWAYS black, both themes val Surface: Color, // solid "no video here" backdrop val Tile: Color, val Scrim: Color, val Glass: Color, val GlassStrong: Color, val GlassBorder: Color, val OnGlass: Color, val OnGlassMuted: Color, val Accent: Color, val Positive: Color, val Danger: Color, )
  • DarkCallColors / LightCallColors are provided through a CompositionLocalProvider in VioraTheme, keyed on the resolved dark/light boolean.
  • Exposed as a @Composable @ReadOnlyComposable accessor named CallColors, so every existing call site (CallColors.Glass, …) kept working when the object became a scheme.
  • VideoBackdrop is black in both themes - video and letterbox bars always sit on black; only the chrome (glass pills, tray, “camera off” surfaces) follows the theme.
  • isLight exists because Compose Canvas / drawBehind lambdas are not @Composable and can’t read the accessor. The speaking waveform reads it to switch blend modes.

Speaking waveforms

SpeakingWaveform.kt - a Siri-style 3-band waveform (blue / pink / green filled translucent ribbons) driven by localAudioLevel / remoteAudioLevel. It uses withInfiniteAnimationFrameNanos for frame-driven motion. In dark mode the ribbons draw with BlendMode.Plus (additive glow); in light mode additive blending washes to white, so it switches to normal SrcOver at higher opacity - hence CallColorScheme.isLight.

Off-state mic and camera buttons in CallControls render solid red (CallColors.Danger) so a disabled input is unmistakable.