Make PIP react to camera orientation changes in the other participant's device (#6951)

* Make PIP react to camera orientation changes in the other participant's device

Adds a `controls.onPipMediaOrientationUpdate` callback for the WebView that will trigger a new `pipParamsBuilder.setAspectRatioFromOrientation(orientation)` method

* Fix warning about `PictureInPictureParams.Builder.setAspectRatio` only being available on API 26+
This commit is contained in:
Jorge Martin Espinosa
2026-08-21 11:47:49 +02:00
committed by GitHub
parent 2b3aeee1c6
commit 614619e306
11 changed files with 97 additions and 6 deletions
@@ -11,7 +11,9 @@ package io.element.android.features.call.impl.pip
import io.element.android.features.call.impl.utils.PipController import io.element.android.features.call.impl.utils.PipController
sealed interface PictureInPictureEvent { sealed interface PictureInPictureEvent {
data object OnCallStarted : PictureInPictureEvent
data class SetPipController(val pipController: PipController) : PictureInPictureEvent data class SetPipController(val pipController: PipController) : PictureInPictureEvent
data object EnterPictureInPicture : PictureInPictureEvent data object EnterPictureInPicture : PictureInPictureEvent
data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvent data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvent
data class SetPipOrientation(val orientation: Int?) : PictureInPictureEvent
} }
@@ -38,6 +38,9 @@ class PictureInPicturePresenter(
fun handleEvent(event: PictureInPictureEvent) { fun handleEvent(event: PictureInPictureEvent) {
when (event) { when (event) {
is PictureInPictureEvent.OnCallStarted -> {
pipController?.setupPipCallbacks()
}
is PictureInPictureEvent.SetPipController -> { is PictureInPictureEvent.SetPipController -> {
pipController = event.pipController pipController = event.pipController
} }
@@ -55,6 +58,10 @@ class PictureInPicturePresenter(
pipController?.exitPip() pipController?.exitPip()
} }
} }
is PictureInPictureEvent.SetPipOrientation -> {
Timber.tag(loggerTag.value).d("onOrientationChange: ${event.orientation}")
pipView?.setPipOrientation(event.orientation)
}
} }
} }
@@ -9,6 +9,7 @@
package io.element.android.features.call.impl.pip package io.element.android.features.call.impl.pip
interface PipView { interface PipView {
fun setPipOrientation(orientation: Int?)
fun setPipParams() fun setPipParams()
fun enterPipMode(): Boolean fun enterPipMode(): Boolean
fun hangUp() fun hangUp()
@@ -118,6 +118,7 @@ internal fun CallScreenView(
if (webViewAudioManager?.isInCallMode?.get() == false) { if (webViewAudioManager?.isInCallMode?.get() == false) {
Timber.d("URL $url is loaded, starting in-call audio mode") Timber.d("URL $url is loaded, starting in-call audio mode")
webViewAudioManager?.onCallStarted() webViewAudioManager?.onCallStarted()
pipState.eventSink(PictureInPictureEvent.OnCallStarted)
} else { } else {
Timber.d("Can't start in-call audio mode since the app is already in it.") Timber.d("Can't start in-call audio mode since the app is already in it.")
} }
@@ -130,7 +131,12 @@ internal fun CallScreenView(
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it }, onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
) )
state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor)) state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor))
val pipController = WebViewPipController(webView) val pipController = WebViewPipController(
webView = webView,
updatePipOrientation = { orientation ->
pipState.eventSink(PictureInPictureEvent.SetPipOrientation(orientation))
}
)
pipState.eventSink(PictureInPictureEvent.SetPipController(pipController)) pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
}, },
onDestroyWebView = { onDestroyWebView = {
@@ -11,9 +11,9 @@ package io.element.android.features.call.impl.ui
import android.Manifest import android.Manifest
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Rational
import android.view.WindowManager import android.view.WindowManager
import android.webkit.PermissionRequest import android.webkit.PermissionRequest
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -49,6 +49,7 @@ import io.element.android.features.call.impl.pip.PipView
import io.element.android.features.call.impl.services.CallForegroundService import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger
import io.element.android.libraries.androidutils.media.setAspectRatioFromOrientation
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.audio.api.AudioFocus
@@ -86,6 +87,8 @@ class ElementCallActivity :
private var eventSink: ((CallScreenEvent) -> Unit)? = null private var eventSink: ((CallScreenEvent) -> Unit)? = null
private var currentPipOrientation: Int? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -199,6 +202,7 @@ class ElementCallActivity :
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo -> val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
setPipParams()
pipEventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(isInPictureInPictureMode)) pipEventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(isInPictureInPictureMode))
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call") Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call")
@@ -296,11 +300,17 @@ class ElementCallActivity :
} }
} }
override fun setPipOrientation(orientation: Int?) {
currentPipOrientation = orientation
setPictureInPictureParams(getPictureInPictureParams())
}
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun getPictureInPictureParams(): PictureInPictureParams { private fun getPictureInPictureParams(): PictureInPictureParams {
// Portrait for calls seems more appropriate as a fallback value
val orientation = currentPipOrientation ?: Configuration.ORIENTATION_PORTRAIT
return PictureInPictureParams.Builder() return PictureInPictureParams.Builder()
// Portrait for calls seems more appropriate .setAspectRatioFromOrientation(orientation)
.setAspectRatio(Rational(3, 5))
.apply { .apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setAutoEnterEnabled(true) setAutoEnterEnabled(true)
@@ -9,6 +9,7 @@
package io.element.android.features.call.impl.utils package io.element.android.features.call.impl.utils
interface PipController { interface PipController {
fun setupPipCallbacks()
suspend fun canEnterPip(): Boolean suspend fun canEnterPip(): Boolean
fun enterPip() fun enterPip()
fun exitPip() fun exitPip()
@@ -250,7 +250,7 @@ class WebViewAudioManager(
hasRegisteredCallbacks = true hasRegisteredCallbacks = true
} }
} },
) )
Timber.d("Setting androidNativeBridge javascript interface in webview") Timber.d("Setting androidNativeBridge javascript interface in webview")
webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge") webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge")
@@ -8,7 +8,10 @@
package io.element.android.features.call.impl.utils package io.element.android.features.call.impl.utils
import android.content.res.Configuration
import android.webkit.JavascriptInterface
import android.webkit.WebView import android.webkit.WebView
import timber.log.Timber
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
@@ -18,7 +21,18 @@ import kotlin.coroutines.suspendCoroutine
*/ */
class WebViewPipController( class WebViewPipController(
private val webView: WebView, private val webView: WebView,
updatePipOrientation: (orientation: Int?) -> Unit,
) : PipController { ) : PipController {
init {
// Register the JavaScript interface ahead of time to ensure it's available when the WebView content is loaded and tries to call it
webView.addJavascriptInterface(PipBridge(updatePipOrientation), "androidPipBridge")
}
override fun setupPipCallbacks() {
Timber.d("Adding callback in controls.onPipMediaOrientationUpdate")
webView.evaluateJavascript("controls.onPipMediaOrientationUpdate = (orientation) => { androidPipBridge.setPipOrientation(orientation); };", null)
}
override suspend fun canEnterPip(): Boolean { override suspend fun canEnterPip(): Boolean {
return suspendCoroutine { continuation -> return suspendCoroutine { continuation ->
webView.evaluateJavascript("controls.canEnterPip()") { result -> webView.evaluateJavascript("controls.canEnterPip()") { result ->
@@ -36,3 +50,19 @@ class WebViewPipController(
webView.evaluateJavascript("controls.disablePip()", null) webView.evaluateJavascript("controls.disablePip()", null)
} }
} }
private class PipBridge(
private val onOrientationChange: (orientation: Int?) -> Unit,
) {
@JavascriptInterface
fun setPipOrientation(orientation: String?) {
Timber.d("Picture-in-picture orientation changed in webview, orientation: $orientation")
// This callback is used to update the picture-in-picture orientation in the WebView when it changes
val orientation = when (orientation) {
"portrait" -> Configuration.ORIENTATION_PORTRAIT
"landscape" -> Configuration.ORIENTATION_LANDSCAPE
else -> return
}
onOrientationChange(orientation)
}
}
@@ -12,10 +12,13 @@ import io.element.android.features.call.impl.utils.PipController
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
class FakePipController( class FakePipController(
private val setupPipCallbacksResult: () -> Unit = { lambdaError() },
private val canEnterPipResult: () -> Boolean = { lambdaError() }, private val canEnterPipResult: () -> Boolean = { lambdaError() },
private val enterPipResult: () -> Unit = { lambdaError() }, private val enterPipResult: () -> Unit = { lambdaError() },
private val exitPipResult: () -> Unit = { lambdaError() }, private val exitPipResult: () -> Unit = { lambdaError() },
) : PipController { ) : PipController {
override fun setupPipCallbacks() = setupPipCallbacksResult()
override suspend fun canEnterPip(): Boolean = canEnterPipResult() override suspend fun canEnterPip(): Boolean = canEnterPipResult()
override fun enterPip() = enterPipResult() override fun enterPip() = enterPipResult()
@@ -13,9 +13,11 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakePipView( class FakePipView(
private val setPipParamsResult: () -> Unit = { lambdaError() }, private val setPipParamsResult: () -> Unit = { lambdaError() },
private val enterPipModeResult: () -> Boolean = { lambdaError() }, private val enterPipModeResult: () -> Boolean = { lambdaError() },
private val handUpResult: () -> Unit = { lambdaError() } private val handUpResult: () -> Unit = { lambdaError() },
private val onOrientationChangeResult: (orientation: Int?) -> Unit = { lambdaError() },
) : PipView { ) : PipView {
override fun setPipParams() = setPipParamsResult() override fun setPipParams() = setPipParamsResult()
override fun enterPipMode(): Boolean = enterPipModeResult() override fun enterPipMode(): Boolean = enterPipModeResult()
override fun hangUp() = handUpResult() override fun hangUp() = handUpResult()
override fun setPipOrientation(orientation: Int?) = onOrientationChangeResult(orientation)
} }
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.media
import android.app.PictureInPictureParams
import android.content.res.Configuration
import android.os.Build
import android.util.Rational
import androidx.annotation.RequiresApi
/**
* Set the aspect ratio of the Picture-in-Picture mode based on the current orientation of the other user's device.
*/
@RequiresApi(Build.VERSION_CODES.O)
fun PictureInPictureParams.Builder.setAspectRatioFromOrientation(orientation: Int): PictureInPictureParams.Builder {
val aspectRatio = if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
// If landscape orientation, invert the aspect ratio
Rational(5, 3)
} else {
// In any other case, assume portrait orientation
Rational(3, 5)
}
return setAspectRatio(aspectRatio)
}