Skip to content

Commit 99655c5

Browse files
authored
Add support for PiP for FrontendScreen (#6812)
1 parent cf86a23 commit 99655c5

14 files changed

Lines changed: 478 additions & 14 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@
112112
<activity
113113
android:name="io.homeassistant.companion.android.launch.LaunchActivity"
114114
android:exported="true"
115+
android:supportsPictureInPicture="true"
115116
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
116117
android:windowSoftInputMode="adjustResize"
117118
android:theme="@style/Theme.LaunchScreen">

app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import androidx.annotation.VisibleForTesting
1212
import androidx.compose.foundation.background
1313
import androidx.compose.foundation.layout.Arrangement
1414
import androidx.compose.foundation.layout.Box
15+
import androidx.compose.foundation.layout.BoxScope
1516
import androidx.compose.foundation.layout.Column
1617
import androidx.compose.foundation.layout.Row
1718
import androidx.compose.foundation.layout.Spacer
@@ -65,6 +66,7 @@ import io.homeassistant.companion.android.frontend.permissions.MultiplePermissio
6566
import io.homeassistant.companion.android.frontend.permissions.NotificationPermissionPrompt
6667
import io.homeassistant.companion.android.frontend.permissions.PermissionRequest
6768
import io.homeassistant.companion.android.frontend.permissions.SinglePermissionEffect
69+
import io.homeassistant.companion.android.launch.PipReadiness
6870
import io.homeassistant.companion.android.loading.LoadingScreen
6971
import io.homeassistant.companion.android.onboarding.locationforsecureconnection.LocationForSecureConnectionScreen
7072
import io.homeassistant.companion.android.onboarding.locationforsecureconnection.LocationForSecureConnectionViewModel
@@ -115,6 +117,7 @@ internal fun FrontendScreen(
115117
onSecurityLevelHelpClick: suspend () -> Unit,
116118
onShowSnackbar: suspend (message: String, action: String?) -> Boolean,
117119
modifier: Modifier = Modifier,
120+
onPipReadinessChanged: (PipReadiness?) -> Unit = {},
118121
) {
119122
val viewState by viewModel.viewState.collectAsStateWithLifecycle()
120123
val pendingPermissionRequest by viewModel.pendingPermissionRequest.collectAsStateWithLifecycle()
@@ -170,6 +173,7 @@ internal fun FrontendScreen(
170173
onGesture = viewModel::onGesture,
171174
onExoPlayerFullscreenChanged = viewModel::onExoPlayerFullscreenChanged,
172175
autoPlayVideoEnabled = autoPlayVideoEnabled,
176+
onPipReadinessChanged = onPipReadinessChanged,
173177
modifier = modifier,
174178
)
175179
}
@@ -204,6 +208,7 @@ internal fun FrontendScreenContent(
204208
webViewActions: Flow<WebViewAction> = emptyFlow(),
205209
onGesture: (GestureDirection, Int) -> Unit = { _, _ -> },
206210
onExoPlayerFullscreenChanged: (Boolean) -> Unit = {},
211+
onPipReadinessChanged: (PipReadiness?) -> Unit = {},
207212
) {
208213
var webView by remember { mutableStateOf<WebView?>(null) }
209214

@@ -241,13 +246,13 @@ internal fun FrontendScreenContent(
241246
autoPlayVideoEnabled = autoPlayVideoEnabled,
242247
)
243248

244-
ExoPlayerOverlay(
249+
PipEligibleOverlays(
245250
contentState = viewState as? FrontendViewState.Content,
246-
onFullscreenChanged = onExoPlayerFullscreenChanged,
251+
customView = customView,
252+
onExoPlayerFullscreenChanged = onExoPlayerFullscreenChanged,
253+
onPipReadinessChanged = onPipReadinessChanged,
247254
)
248255

249-
CustomViewOverlay(customView = customView)
250-
251256
StateOverlay(
252257
viewState = viewState,
253258
errorStateProvider = errorStateProvider,
@@ -582,19 +587,24 @@ private fun PendingPermissionHandler(pendingRequest: PermissionRequest?) {
582587
onDismiss = pendingRequest.onDismiss,
583588
)
584589
}
590+
585591
is PermissionRequest.MultiplePermissions -> {
586592
MultiplePermissionsEffect(
587593
pendingRequest = pendingRequest,
588594
onPermissionResult = pendingRequest.onResult,
589595
)
590596
}
597+
591598
is PermissionRequest.SinglePermission -> {
592599
SinglePermissionEffect(
593600
pendingRequest = pendingRequest,
594601
onPermissionResult = pendingRequest.onResult,
595602
)
596603
}
597-
null -> { /* No pending permission */ }
604+
605+
null -> {
606+
/* No pending permission */
607+
}
598608
}
599609
}
600610

@@ -635,6 +645,33 @@ private fun WebViewEffects(
635645
}
636646
}
637647

648+
/**
649+
* Renders PiP-eligible overlays and reports their combined [PipReadiness] to the host.
650+
*/
651+
@Composable
652+
private fun BoxScope.PipEligibleOverlays(
653+
contentState: FrontendViewState.Content?,
654+
customView: View?,
655+
onExoPlayerFullscreenChanged: (Boolean) -> Unit,
656+
onPipReadinessChanged: (PipReadiness?) -> Unit,
657+
) {
658+
val exoState = contentState?.exoPlayerState
659+
val readiness = remember(customView, exoState?.isFullScreen, exoState?.videoAspectRatio) {
660+
PipReadiness.from(customViewShown = customView != null, exoState = exoState)
661+
}
662+
663+
LaunchedEffect(readiness) { onPipReadinessChanged(readiness) }
664+
DisposableEffect(Unit) {
665+
onDispose { onPipReadinessChanged(null) }
666+
}
667+
668+
ExoPlayerOverlay(
669+
contentState = contentState,
670+
onFullscreenChanged = onExoPlayerFullscreenChanged,
671+
)
672+
CustomViewOverlay(customView = customView)
673+
}
674+
638675
@Composable
639676
private fun CustomViewOverlay(customView: View?) {
640677
val view: View = customView ?: return

app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import io.homeassistant.companion.android.common.data.servers.ServerManager.Comp
1919
import io.homeassistant.companion.android.frontend.FrontendScreen
2020
import io.homeassistant.companion.android.frontend.FrontendViewModel
2121
import io.homeassistant.companion.android.launch.HAStartDestinationRoute
22+
import io.homeassistant.companion.android.launch.PipReadiness
2223
import io.homeassistant.companion.android.nfc.WriteNfcTag
2324
import io.homeassistant.companion.android.settings.SettingsActivity
2425
import io.homeassistant.companion.android.util.getActivity
@@ -72,6 +73,7 @@ internal fun NavGraphBuilder.frontendScreen(
7273
onShowSnackbar: suspend (message: String, action: String?) -> Boolean,
7374
onShowServerSwitcher: (onServerSelected: (Int) -> Unit) -> Unit,
7475
onRequestFullscreen: (Boolean) -> Unit = {},
76+
onPipReadinessChanged: (PipReadiness?) -> Unit = {},
7577
) {
7678
if (WIPFeature.USE_FRONTEND_V2) {
7779
composable<FrontendRoute> {
@@ -113,6 +115,7 @@ internal fun NavGraphBuilder.frontendScreen(
113115
onConfigureHomeNetwork = onConfigureHomeNetwork,
114116
onSecurityLevelHelpClick = onSecurityLevelHelpClick,
115117
onShowSnackbar = onShowSnackbar,
118+
onPipReadinessChanged = onPipReadinessChanged,
116119
)
117120
}
118121
} else {

app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package io.homeassistant.companion.android.launch
22

3+
import android.app.PictureInPictureParams
34
import android.content.Context
45
import android.content.Intent
6+
import android.content.pm.PackageManager
7+
import android.os.Build
58
import android.os.Bundle
69
import android.os.Parcelable
710
import androidx.activity.compose.LocalActivity
@@ -159,6 +162,7 @@ class LaunchActivity : AppCompatActivity() {
159162
startDestination = (uiState as? LaunchUiState.Ready)?.startDestination,
160163
snackbarHostState = snackbarHostState,
161164
onRequestFullscreen = viewModel::onFullscreenRequested,
165+
onPipReadinessChanged = viewModel::onPipReadinessChanged,
162166
modifier = Modifier.hazeSource(hazeState),
163167
)
164168

@@ -203,6 +207,22 @@ class LaunchActivity : AppCompatActivity() {
203207
if (!isFinishing && WIPFeature.USE_FRONTEND_V2) SensorReceiver.updateAllSensors(this)
204208
}
205209

210+
override fun onUserLeaveHint() {
211+
super.onUserLeaveHint()
212+
if (WIPFeature.USE_FRONTEND_V2) {
213+
viewModel.onAppPaused()
214+
215+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
216+
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) return
217+
val readiness = viewModel.pipReadiness.value ?: return
218+
val params = PictureInPictureParams.Builder()
219+
.setAspectRatio(readiness.aspectRatio)
220+
.apply { readiness.sourceRect?.let(::setSourceRectHint) }
221+
.build()
222+
enterPictureInPictureMode(params)
223+
}
224+
}
225+
206226
override fun onStop() {
207227
super.onStop()
208228
if (WIPFeature.USE_FRONTEND_V2) {

app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchViewModel.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,17 @@ internal class LaunchViewModel @VisibleForTesting constructor(
134134
private val _isAppLocked = MutableStateFlow(false)
135135
val isAppLocked: StateFlow<Boolean> = _isAppLocked.asStateFlow()
136136

137+
private val _pipReadiness = MutableStateFlow<PipReadiness?>(null)
138+
139+
/**
140+
* Latest [PipReadiness] reported by the active screen, or `null` if no screen is currently
141+
* displaying PiP-eligible content.
142+
*
143+
* Read by [LaunchActivity] to build [android.app.PictureInPictureParams] when the user
144+
* backgrounds the app or when `setAutoEnterEnabled` is honored by the OS (API 31+).
145+
*/
146+
val pipReadiness: StateFlow<PipReadiness?> = _pipReadiness.asStateFlow()
147+
137148
init {
138149
viewModelScope.launch {
139150
cleanupServers()
@@ -185,6 +196,13 @@ internal class LaunchViewModel @VisibleForTesting constructor(
185196
fullscreenRequested.value = fullscreen
186197
}
187198

199+
/**
200+
* Updates [pipReadiness] from the screen layer. `null` indicates no PiP-eligible content.
201+
*/
202+
fun onPipReadinessChanged(readiness: PipReadiness?) {
203+
_pipReadiness.value = readiness
204+
}
205+
188206
private suspend fun handleInitialState(initialDeepLink: LaunchActivity.DeepLink?) {
189207
when (initialDeepLink) {
190208
is LaunchActivity.DeepLink.OpenOnboarding -> navigateToOnboarding(
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package io.homeassistant.companion.android.launch
2+
3+
import android.graphics.Rect
4+
import android.util.Rational
5+
import io.homeassistant.companion.android.frontend.exoplayer.ExoPlayerUiState
6+
7+
/**
8+
* Narrowest aspect ratio (width:height) accepted by
9+
* [android.app.PictureInPictureParams.Builder.setAspectRatio], per its documented range of
10+
* 1:2.39 to 2.39:1 inclusive. We clamp to it so the system does not reject the params later
11+
* when entering PiP.
12+
*/
13+
private val PIP_MIN_ASPECT = Rational(100, 239)
14+
15+
/**
16+
* Widest aspect ratio (width:height) accepted by
17+
* [android.app.PictureInPictureParams.Builder.setAspectRatio], per its documented range of
18+
* 1:2.39 to 2.39:1 inclusive. We clamp to it so the system does not reject the params later
19+
* when entering PiP.
20+
*/
21+
private val PIP_MAX_ASPECT = Rational(239, 100)
22+
23+
/**
24+
* Fallback PiP aspect ratio used when the source content does not advertise its own —
25+
* e.g. a WebView custom view, or a video that has not yet reported its dimensions. 16:9
26+
* matches the most common video framing and sits comfortably inside the PiP allowed range.
27+
*/
28+
private val PIP_DEFAULT_ASPECT = Rational(16, 9)
29+
30+
/**
31+
* Snapshot of "the screen has PiP-eligible content" along with the parameters needed to enter
32+
* Picture-in-Picture mode.
33+
*
34+
* `null` upstream of this type means the host activity must not enter PiP. Both fields are
35+
* value types — keeping `android.app.PictureInPictureParams` (API 26+) out of the ViewModel
36+
* avoids leaking an Android system type into shared Compose plumbing.
37+
*
38+
* @param aspectRatio Pre-clamped to Android's allowed PiP range `[1:2.39, 2.39:1]`.
39+
* @param sourceRect Optional layout hint used for the launch animation. `null` lets Android
40+
* infer it from the activity's content.
41+
*/
42+
data class PipReadiness(val aspectRatio: Rational, val sourceRect: Rect? = null) {
43+
companion object {
44+
/**
45+
* Computes the [PipReadiness] snapshot for the current screen state.
46+
*
47+
* Returns `null` when no PiP-eligible content is showing. When both an ExoPlayer fullscreen
48+
* stream and a custom view are simultaneously present (theoretically possible — different
49+
* frontend paths choose between them), the player is the more specific signal and wins.
50+
*
51+
* The returned aspect ratio is clamped to Android's allowed PiP range `[1:2.39, 2.39:1]` so
52+
* `PictureInPictureParams.Builder.setAspectRatio` cannot throw.
53+
*/
54+
fun from(customViewShown: Boolean, exoState: ExoPlayerUiState?): PipReadiness? {
55+
val playerFullScreen = exoState?.isFullScreen == true
56+
if (!playerFullScreen && !customViewShown) return null
57+
58+
val aspect = if (playerFullScreen) {
59+
exoState.videoAspectRatio?.let(::aspectFromHeightOverWidth) ?: PIP_DEFAULT_ASPECT
60+
} else {
61+
PIP_DEFAULT_ASPECT
62+
}
63+
return PipReadiness(aspectRatio = aspect.coerceWithinPipRange())
64+
}
65+
66+
private fun Rational.coerceWithinPipRange(): Rational = when {
67+
toFloat() < PIP_MIN_ASPECT.toFloat() -> PIP_MIN_ASPECT
68+
toFloat() > PIP_MAX_ASPECT.toFloat() -> PIP_MAX_ASPECT
69+
else -> this
70+
}
71+
72+
private fun aspectFromHeightOverWidth(heightOverWidth: Double): Rational {
73+
// ExoPlayer stores ratio as height/width; PictureInPictureParams wants width:height.
74+
// Multiply by 1_000 before truncating to integers so we keep three significant digits
75+
// before `Rational`'s built-in reduction collapses common cases like 16:9 or 4:3.
76+
val widthScaled = 1_000.0
77+
val heightScaled = heightOverWidth * 1_000.0
78+
return Rational(
79+
widthScaled.toInt().coerceAtLeast(1),
80+
heightScaled.toInt().coerceAtLeast(1),
81+
)
82+
}
83+
}
84+
}

app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HAApp.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
1919
import androidx.navigation.NavHostController
2020
import io.homeassistant.companion.android.common.compose.theme.LocalHAColorScheme
2121
import io.homeassistant.companion.android.launch.HAStartDestinationRoute
22+
import io.homeassistant.companion.android.launch.PipReadiness
2223
import io.homeassistant.companion.android.loading.LoadingScreen
2324

2425
/**
@@ -43,6 +44,7 @@ internal fun HAApp(
4344
snackbarHostState: SnackbarHostState,
4445
modifier: Modifier = Modifier,
4546
onRequestFullscreen: (Boolean) -> Unit = {},
47+
onPipReadinessChanged: (PipReadiness?) -> Unit = {},
4648
) {
4749
Scaffold(
4850
modifier = modifier,
@@ -71,6 +73,7 @@ internal fun HAApp(
7173
navController = navController,
7274
startDestination = startDestination,
7375
onRequestFullscreen = onRequestFullscreen,
76+
onPipReadinessChanged = onPipReadinessChanged,
7477
onShowSnackbar = { message, action ->
7578
snackbarHostState.showSnackbar(
7679
message,

app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HANavHost.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import io.homeassistant.companion.android.frontend.navigation.FrontendRoute
1616
import io.homeassistant.companion.android.frontend.navigation.frontendScreen
1717
import io.homeassistant.companion.android.frontend.navigation.navigateToFrontend
1818
import io.homeassistant.companion.android.launch.HAStartDestinationRoute
19+
import io.homeassistant.companion.android.launch.PipReadiness
1920
import io.homeassistant.companion.android.loading.LoadingScreen
2021
import io.homeassistant.companion.android.loading.navigation.LoadingRoute
2122
import io.homeassistant.companion.android.loading.navigation.loadingScreen
@@ -51,6 +52,7 @@ internal fun HANavHost(
5152
startDestination: HAStartDestinationRoute?,
5253
onShowSnackbar: suspend (message: String, action: String?) -> Boolean,
5354
onRequestFullscreen: (Boolean) -> Unit = {},
55+
onPipReadinessChanged: (PipReadiness?) -> Unit = {},
5456
) {
5557
val activity = LocalActivity.current
5658
val isAutomotive = activity?.isAutomotive() == true
@@ -123,6 +125,7 @@ internal fun HANavHost(
123125
onShowSnackbar = onShowSnackbar,
124126
onShowServerSwitcher = { onServerSelected -> showServerSwitcher(activity, onServerSelected) },
125127
onRequestFullscreen = onRequestFullscreen,
128+
onPipReadinessChanged = onPipReadinessChanged,
126129
)
127130
setHomeNetworkScreen(
128131
onGotoNextScreen = {

0 commit comments

Comments
 (0)