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()returns16:9only when the peer is screen-sharing and their content is landscape (derived fromremoteContentRotation/remoteContentLandscape), otherwise9:16. - Actions are
RemoteActions backed by aPendingIntent.getBroadcast. ABroadcastReceiverregistered withRECEIVER_NOT_EXPORTEDhandles 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/LightCallColorsare provided through aCompositionLocalProviderinVioraTheme, keyed on the resolved dark/light boolean.- Exposed as a
@Composable @ReadOnlyComposableaccessor namedCallColors, so every existing call site (CallColors.Glass, …) kept working when the object became a scheme. VideoBackdropis black in both themes - video and letterbox bars always sit on black; only the chrome (glass pills, tray, “camera off” surfaces) follows the theme.isLightexists because ComposeCanvas/drawBehindlambdas are not@Composableand 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.