From 786b4395fb5de8a1fa9861526a80015a301aa047 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 5 Apr 2026 16:44:14 +0530 Subject: [PATCH] feat: adding pip support for android and handling other deprecation warnings --- .../src/androidMain/AndroidManifest.xml | 2 + .../kotlin/com/nuvio/app/MainActivity.kt | 10 ++- .../features/player/PlayerEngine.android.kt | 20 ++++- .../PlayerPictureInPictureManager.android.kt | 83 +++++++++++++++++++ .../player/PlayerPlatformEffects.android.kt | 33 ++++++++ .../src/androidMain/res/values-v31/themes.xml | 2 - .../src/androidMain/res/values/themes.xml | 2 - .../features/player/PlayerPlatformEffects.kt | 7 ++ .../nuvio/app/features/player/PlayerScreen.kt | 5 ++ .../player/PlayerPlatformEffects.ios.kt | 7 ++ iosApp/Configuration/Version.xcconfig | 4 +- 11 files changed, 164 insertions(+), 11 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 5ed3bab69..57d120a74 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -14,6 +14,8 @@ diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index bb3590f5b..a2ad56a91 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -7,7 +7,6 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.SystemBarStyle import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.core.view.WindowCompat import com.nuvio.app.core.auth.AuthStorage import com.nuvio.app.core.deeplink.handleAppUrl import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner @@ -19,6 +18,7 @@ import com.nuvio.app.features.mdblist.MdbListSettingsStorage import com.nuvio.app.features.notifications.EpisodeReleaseNotificationPlatform import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage import com.nuvio.app.features.player.PlayerSettingsStorage +import com.nuvio.app.features.player.PlayerPictureInPictureManager import com.nuvio.app.features.plugins.PluginStorage import com.nuvio.app.features.profiles.ProfileStorage import com.nuvio.app.features.details.SeasonViewModeStorage @@ -42,9 +42,6 @@ class MainActivity : ComponentActivity() { ) super.onCreate(savedInstanceState) window.setBackgroundDrawableResource(R.color.nuvio_background) - window.navigationBarColor = getColor(R.color.nuvio_background) - window.isNavigationBarContrastEnforced = false - WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = false AddonStorage.initialize(applicationContext) AuthStorage.initialize(applicationContext) LibraryStorage.initialize(applicationContext) @@ -81,6 +78,11 @@ class MainActivity : ComponentActivity() { handleIncomingAppIntent(intent) } + override fun onUserLeaveHint() { + super.onUserLeaveHint() + PlayerPictureInPictureManager.onUserLeaveHint(this) + } + override fun onDestroy() { EpisodeReleaseNotificationPlatform.unbindActivity(this) super.onDestroy() diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 4c16762cc..029dc9c64 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -1,9 +1,13 @@ package com.nuvio.app.features.player +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper import android.net.Uri import android.util.Log import android.util.TypedValue import android.graphics.Typeface +import android.os.Build import android.view.ViewGroup.LayoutParams.MATCH_PARENT import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -195,10 +199,17 @@ actual fun PlatformPlayerSurface( } DisposableEffect(exoPlayer, lifecycleOwner) { + val activity = context.findActivity() val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_START -> exoPlayer.playWhenReady = playWhenReady - Lifecycle.Event.ON_STOP -> exoPlayer.pause() + Lifecycle.Event.ON_STOP -> { + val isInPictureInPicture = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity?.isInPictureInPictureMode == true + if (!isInPictureInPicture) { + exoPlayer.pause() + } + } else -> Unit } } @@ -388,6 +399,13 @@ actual fun PlatformPlayerSurface( ) } +private tailrec fun Context.findActivity(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null + } + private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot = PlayerPlaybackSnapshot( isLoading = playbackState == Player.STATE_IDLE || playbackState == Player.STATE_BUFFERING, diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt new file mode 100644 index 000000000..f5466f054 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt @@ -0,0 +1,83 @@ +package com.nuvio.app.features.player + +import android.app.Activity +import android.app.PictureInPictureParams +import android.os.Build +import android.util.Rational +import androidx.compose.ui.unit.IntSize + +internal object PlayerPictureInPictureManager { + private data class SessionState( + val isActive: Boolean = false, + val isPlaying: Boolean = false, + val playerSize: IntSize = IntSize.Zero, + ) + + private var sessionState = SessionState() + + fun updateSession( + activity: Activity, + isActive: Boolean, + isPlaying: Boolean, + playerSize: IntSize, + ) { + sessionState = SessionState( + isActive = isActive, + isPlaying = isPlaying, + playerSize = playerSize, + ) + applyPictureInPictureParams(activity) + } + + fun clearSession(activity: Activity) { + sessionState = SessionState() + applyPictureInPictureParams(activity) + } + + fun onUserLeaveHint(activity: Activity): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return false + } + return enterIfEligible(activity) + } + + private fun applyPictureInPictureParams(activity: Activity) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + activity.setPictureInPictureParams(buildParams()) + } + + private fun enterIfEligible(activity: Activity): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + if (!sessionState.isActive || !sessionState.isPlaying) return false + if (activity.isFinishing) return false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity.isInPictureInPictureMode) return false + return activity.enterPictureInPictureMode(buildParams()) + } + + private fun buildParams(): PictureInPictureParams { + val builder = PictureInPictureParams.Builder() + buildAspectRatio(sessionState.playerSize)?.let(builder::setAspectRatio) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(sessionState.isActive && sessionState.isPlaying) + builder.setSeamlessResizeEnabled(true) + } + return builder.build() + } + + private fun buildAspectRatio(playerSize: IntSize): Rational? { + if (playerSize.width <= 0 || playerSize.height <= 0) return null + + val width = playerSize.width.coerceAtLeast(1) + val height = playerSize.height.coerceAtLeast(1) + val ratio = width.toDouble() / height.toDouble() + + return when { + ratio > MaxPictureInPictureAspectRatio -> Rational(239, 100) + ratio < MinPictureInPictureAspectRatio -> Rational(100, 239) + else -> Rational(width, height) + } + } +} + +private const val MaxPictureInPictureAspectRatio = 2.39 +private const val MinPictureInPictureAspectRatio = 1.0 / MaxPictureInPictureAspectRatio \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt index 78f45302d..bc1a87346 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt @@ -5,11 +5,14 @@ import android.content.Context import android.content.ContextWrapper import android.content.pm.ActivityInfo import android.media.AudioManager +import android.os.Build import android.provider.Settings import android.view.WindowManager import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.platform.LocalContext import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -19,6 +22,7 @@ import kotlin.math.roundToInt @Composable actual fun LockPlayerToLandscape() { val activity = LocalContext.current.findActivity() ?: return + if (!activity.shouldForceLandscapePlayer()) return DisposableEffect(activity) { val previousOrientation = activity.requestedOrientation @@ -50,6 +54,29 @@ actual fun EnterImmersivePlayerMode() { } } +@Composable +actual fun ManagePlayerPictureInPicture( + isPlaying: Boolean, + playerSize: IntSize, +) { + val activity = LocalContext.current.findActivity() ?: return + + DisposableEffect(activity) { + onDispose { + PlayerPictureInPictureManager.clearSession(activity) + } + } + + SideEffect { + PlayerPictureInPictureManager.updateSession( + activity = activity, + isActive = true, + isPlaying = isPlaying, + playerSize = playerSize, + ) + } +} + @Composable actual fun rememberPlayerGestureController(): PlayerGestureController? { val context = LocalContext.current @@ -79,6 +106,12 @@ private tailrec fun Context.findActivity(): Activity? = else -> null } +private fun Activity.shouldForceLandscapePlayer(): Boolean { + if (resources.configuration.smallestScreenWidthDp >= 600) return false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode) return false + return true +} + private class AndroidPlayerGestureController( private val activity: Activity, private val audioManager: AudioManager, diff --git a/composeApp/src/androidMain/res/values-v31/themes.xml b/composeApp/src/androidMain/res/values-v31/themes.xml index 38d023a08..28ee2797c 100644 --- a/composeApp/src/androidMain/res/values-v31/themes.xml +++ b/composeApp/src/androidMain/res/values-v31/themes.xml @@ -2,8 +2,6 @@