mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 10:32:34 +00:00
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:
committed by
GitHub
parent
2b3aeee1c6
commit
614619e306
+2
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -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()
|
||||||
|
|||||||
+7
-1
@@ -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 = {
|
||||||
|
|||||||
+13
-3
@@ -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)
|
||||||
|
|||||||
+1
@@ -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()
|
||||||
|
|||||||
+1
-1
@@ -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")
|
||||||
|
|||||||
+30
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
@@ -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()
|
||||||
|
|||||||
+3
-1
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+29
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user