fix(android): fix issues with PiP window

This commit is contained in:
darkflame91 2026-07-12 17:27:36 +05:30
parent f19d3d1a08
commit 4db3231c2a
9 changed files with 210 additions and 28 deletions

View file

@ -32,6 +32,7 @@ import com.nuvio.app.features.player.PlayerTrackPreferenceStorage
import com.nuvio.app.features.player.ExternalPlayerPlatform
import com.nuvio.app.features.player.SubtitleFileCache
import com.nuvio.app.features.player.PlayerPictureInPictureManager
import com.nuvio.app.features.player.PipRemoteActionReceiver
import com.nuvio.app.features.p2p.P2pSettingsStorage
import com.nuvio.app.features.p2p.P2pStreamingEngine
import com.nuvio.app.features.plugins.PluginStorage
@ -59,6 +60,8 @@ import com.nuvio.app.features.watchprogress.ResumePromptStorage
import com.nuvio.app.features.watchprogress.WatchProgressStorage
class MainActivity : AppCompatActivity() {
private var pipRemoteActionReceiver: PipRemoteActionReceiver? = null
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge(
@ -71,6 +74,7 @@ class MainActivity : AppCompatActivity() {
SentryInitializer.start(application)
super.onCreate(savedInstanceState)
window.setBackgroundDrawableResource(R.color.nuvio_background)
pipRemoteActionReceiver = PipRemoteActionReceiver.register(this)
SyncClientIdentityStorage.initialize(applicationContext)
AddonStorage.initialize(applicationContext)
AuthStorage.initialize(applicationContext)
@ -143,6 +147,11 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
EpisodeReleaseNotificationPlatform.unbindActivity(this)
val receiver = pipRemoteActionReceiver
if (receiver != null) {
runCatching { unregisterReceiver(receiver) }
pipRemoteActionReceiver = null
}
super.onDestroy()
}

View file

@ -20,6 +20,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
@ -69,6 +70,7 @@ import `is`.xyz.mpv.Utils
import io.github.peerless2012.ass.media.widget.AssSubtitleView
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
import kotlin.math.roundToInt
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@ -371,6 +373,8 @@ private fun ExoPlayerSurface(
var playerViewRef by remember { mutableStateOf<PlayerView?>(null) }
var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) }
var subtitleSelectionJob by remember { mutableStateOf<Job?>(null) }
val isInPip = rememberIsInPictureInPicture()
val pipSubtitleScale by rememberUpdatedState(if (isInPip) 0.4f else 1.0f)
fun syncPlayerViewKeepScreenOn() {
playerViewRef?.keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn()
@ -387,6 +391,16 @@ private fun ExoPlayerSurface(
PlayerPictureInPictureManager.registerPausePlaybackCallback {
exoPlayer.pause()
}
PlayerPictureInPictureManager.registerTogglePlaybackCallback {
if (exoPlayer.isPlaying) {
exoPlayer.pause()
} else {
if (exoPlayer.playbackState == androidx.media3.common.Player.STATE_ENDED) {
exoPlayer.seekTo(0L)
}
exoPlayer.play()
}
}
fun reportPlayerError(error: PlaybackException) {
if (
@ -471,6 +485,10 @@ private fun ExoPlayerSurface(
latestOnSnapshot.value(exoPlayer.snapshot())
}
override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) {
latestOnSnapshot.value(exoPlayer.snapshot())
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
syncPlayerViewKeepScreenOn()
latestOnSnapshot.value(exoPlayer.snapshot())
@ -508,6 +526,7 @@ private fun ExoPlayerSurface(
exoPlayer.addListener(listener)
onDispose {
PlayerPictureInPictureManager.registerPausePlaybackCallback(null)
PlayerPictureInPictureManager.registerTogglePlaybackCallback(null)
exoPlayer.removeListener(listener)
playerViewRef?.keepScreenOn = false
subtitleSelectionJob?.cancel()
@ -688,7 +707,7 @@ private fun ExoPlayerSurface(
override fun applySubtitleStyle(style: SubtitleStyleState) {
currentSubtitleStyle = style
playerViewRef?.applySubtitleStyle(style)
playerViewRef?.applySubtitleStyle(style, pipSubtitleScale)
}
override fun setSubtitleDelayMs(delayMs: Int) {
@ -721,7 +740,7 @@ private fun ExoPlayerSurface(
enabled = useLibass,
renderType = libassRenderType,
)
applySubtitleStyle(currentSubtitleStyle)
applySubtitleStyle(currentSubtitleStyle, pipSubtitleScale)
}
},
update = { playerView ->
@ -735,7 +754,7 @@ private fun ExoPlayerSurface(
enabled = useLibass,
renderType = libassRenderType,
)
playerView.applySubtitleStyle(currentSubtitleStyle)
playerView.applySubtitleStyle(currentSubtitleStyle, pipSubtitleScale)
},
)
}
@ -851,8 +870,20 @@ private fun LibmpvPlayerSurface(
PlayerPictureInPictureManager.registerPausePlaybackCallback {
view.setPaused(true)
}
PlayerPictureInPictureManager.registerTogglePlaybackCallback {
val snapshot = view.snapshot()
if (snapshot.isPlaying) {
view.setPaused(true)
} else {
if (snapshot.isEnded) {
view.seekToMs(0L)
}
view.setPaused(false)
}
}
onDispose {
PlayerPictureInPictureManager.registerPausePlaybackCallback(null)
PlayerPictureInPictureManager.registerTogglePlaybackCallback(null)
view.keepScreenOn = false
}
}
@ -1025,6 +1056,12 @@ private class NuvioLibmpvView(
runCatching { mpv.setPropertyBoolean("pause", paused) }
}
fun seekToMs(positionMs: Long) {
runCatching {
mpv.command("seek", (positionMs.coerceAtLeast(0L) / 1000.0).toString(), "absolute")
}
}
fun snapshot(): PlayerPlaybackSnapshot {
val paused = mpv.getPropertyBoolean("pause") ?: true
val pausedForCache = mpv.getPropertyBoolean("paused-for-cache") ?: false
@ -1038,6 +1075,12 @@ private class NuvioLibmpvView(
val isCacheBuffering = cacheBufferingState != null && cacheBufferingState in 0 until 100
val isLoading = pausedForCache ||
(!paused && !ended && (seeking || isCacheBuffering || (idle && durationMs <= 0L)))
val videoWidth = mpv.getPropertyInt("video-out-params/dw")
?: mpv.getPropertyInt("video-params/dw")
?: 0
val videoHeight = mpv.getPropertyInt("video-out-params/dh")
?: mpv.getPropertyInt("video-params/dh")
?: 0
return PlayerPlaybackSnapshot(
isLoading = isLoading,
isPlaying = !paused && !isLoading && !idle && !ended,
@ -1046,6 +1089,8 @@ private class NuvioLibmpvView(
positionMs = positionMs,
bufferedPositionMs = maxOf(positionMs, cachePositionMs),
playbackSpeed = (mpv.getPropertyDouble("speed") ?: 1.0).toFloat(),
videoWidth = videoWidth,
videoHeight = videoHeight,
)
}
@ -1276,8 +1321,9 @@ private const val MPV_SUBTITLE_FONT_SIZE_MIN = 36
private const val MPV_SUBTITLE_FONT_SIZE_MAX = 122
private const val MPV_SUBTITLE_OUTLINE_SIZE_SCALE = 1.5
private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot =
PlayerPlaybackSnapshot(
private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot {
val (videoWidth, videoHeight) = videoDimensions()
return PlayerPlaybackSnapshot(
isLoading = playbackState == Player.STATE_IDLE || playbackState == Player.STATE_BUFFERING,
isPlaying = isPlaying,
isEnded = playbackState == Player.STATE_ENDED,
@ -1285,7 +1331,21 @@ private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot =
positionMs = currentPosition.coerceAtLeast(0L),
bufferedPositionMs = bufferedPosition.coerceAtLeast(0L),
playbackSpeed = playbackParameters.speed,
videoWidth = videoWidth,
videoHeight = videoHeight,
)
}
private fun ExoPlayer.videoDimensions(): Pair<Int, Int> {
val format = videoFormat ?: return videoSize.width to videoSize.height
val hasCrop = format.decodedWidth != Format.NO_VALUE &&
format.decodedHeight != Format.NO_VALUE &&
(format.decodedWidth > format.width || format.decodedHeight > format.height)
val baseWidth = if (hasCrop) format.width else (format.width.takeIf { it > 0 } ?: videoSize.width)
val baseHeight = if (hasCrop) format.height else (format.height.takeIf { it > 0 } ?: videoSize.height)
val ratio = format.pixelWidthHeightRatio
return if (ratio != 1f) (baseWidth * ratio).roundToInt() to baseHeight else baseWidth to baseHeight
}
private fun ExoPlayer.shouldKeepPlayerScreenOn(): Boolean =
playerError == null &&
@ -1448,7 +1508,7 @@ private fun android.widget.FrameLayout.removeAssOverlayChildren() {
}
}
private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) {
private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState, pipScale: Float = 1.0f) {
subtitleView?.apply {
val baseBottomPaddingFraction = SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION * 2f / 3f
val offsetFraction = (style.bottomOffset / 1000f).coerceIn(0f, 0.2f)
@ -1467,7 +1527,7 @@ private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) {
if (style.bold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT,
)
)
setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSizeSp.toFloat())
setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSizeSp.toFloat() * pipScale)
}
}

View file

@ -2,37 +2,52 @@ package com.nuvio.app.features.player
import android.app.Activity
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.os.Build
import kotlin.math.roundToInt
import kotlinx.coroutines.runBlocking
import android.os.Handler
import android.os.Looper
import android.util.Rational
import androidx.activity.ComponentActivity
import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.Lifecycle
import com.nuvio.app.R
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
internal const val PIP_ACTION_TOGGLE_PLAY_PAUSE = "com.nuvio.app.action.PIP_TOGGLE_PLAY_PAUSE"
internal object PlayerPictureInPictureManager {
private data class SessionState(
val isActive: Boolean = false,
val isPlaying: Boolean = false,
val playerSize: IntSize = IntSize.Zero,
val videoSize: IntSize = IntSize.Zero,
)
private var sessionState = SessionState()
private var lastAppliedSessionState: SessionState? = null
private val mainHandler = Handler(Looper.getMainLooper())
private var wasInPictureInPictureMode = false
private var pendingPictureInPictureExitCheck: Runnable? = null
private var pausePlaybackCallback: (() -> Unit)? = null
private var togglePlaybackCallback: (() -> Unit)? = null
fun updateSession(
activity: Activity,
isActive: Boolean,
isPlaying: Boolean,
playerSize: IntSize,
videoSize: IntSize,
) {
sessionState = SessionState(
isActive = isActive,
isPlaying = isPlaying,
playerSize = playerSize,
videoSize = videoSize,
)
applyPictureInPictureParams(activity)
}
@ -51,6 +66,10 @@ internal object PlayerPictureInPictureManager {
}
}
fun registerTogglePlaybackCallback(callback: (() -> Unit)?) {
togglePlaybackCallback = callback
}
fun onUserLeaveHint(activity: Activity): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return false
@ -80,9 +99,20 @@ internal object PlayerPictureInPictureManager {
mainHandler.postDelayed(exitCheck, 250L)
}
fun toggleViaRemoteAction(context: Context) {
val wasPlaying = sessionState.isPlaying
togglePlaybackCallback?.invoke()
sessionState = sessionState.copy(isPlaying = !wasPlaying)
if (context is Activity) {
applyPictureInPictureParams(context)
}
}
private fun applyPictureInPictureParams(activity: Activity) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
activity.setPictureInPictureParams(buildParams())
if (sessionState == lastAppliedSessionState) return
lastAppliedSessionState = sessionState
activity.setPictureInPictureParams(buildParams(activity))
}
private fun enterIfEligible(activity: Activity): Boolean {
@ -90,12 +120,13 @@ internal object PlayerPictureInPictureManager {
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())
return activity.enterPictureInPictureMode(buildParams(activity))
}
private fun buildParams(): PictureInPictureParams {
private fun buildParams(activity: Activity): PictureInPictureParams {
val builder = PictureInPictureParams.Builder()
buildAspectRatio(sessionState.playerSize)?.let(builder::setAspectRatio)
buildAspectRatio(sessionState.videoSize)?.let(builder::setAspectRatio)
builder.setActions(buildActions(activity))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(sessionState.isActive && sessionState.isPlaying)
builder.setSeamlessResizeEnabled(true)
@ -103,25 +134,68 @@ internal object PlayerPictureInPictureManager {
return builder.build()
}
private fun buildAspectRatio(playerSize: IntSize): Rational? {
if (playerSize.width <= 0 || playerSize.height <= 0) return null
private fun buildAspectRatio(videoSize: IntSize): Rational? {
if (videoSize.width <= 0 || videoSize.height <= 0) return null
val width = playerSize.width.coerceAtLeast(1)
val height = playerSize.height.coerceAtLeast(1)
val width = videoSize.width.coerceAtLeast(1)
val height = videoSize.height.coerceAtLeast(1)
val ratio = width.toDouble() / height.toDouble()
return when {
ratio > MaxPictureInPictureAspectRatio -> Rational(239, 100)
ratio < MinPictureInPictureAspectRatio -> Rational(100, 239)
ratio > MaxPictureInPictureAspectRatio ->
Rational((MaxPictureInPictureAspectRatio * 100).roundToInt(), 100)
ratio < MinPictureInPictureAspectRatio ->
Rational(100, (MaxPictureInPictureAspectRatio * 100).roundToInt())
else -> Rational(width, height)
}
}
private fun buildActions(activity: Activity): List<RemoteAction> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return emptyList()
val isPlaying = sessionState.isPlaying
val iconRes = if (isPlaying) R.drawable.ic_player_pause else R.drawable.ic_player_play
val title = if (isPlaying) pipPauseLabel else pipPlayLabel
val toggleIntent = Intent(PIP_ACTION_TOGGLE_PLAY_PAUSE).setPackage(activity.packageName)
val flags = android.app.PendingIntent.FLAG_IMMUTABLE or android.app.PendingIntent.FLAG_UPDATE_CURRENT
val pendingIntent = android.app.PendingIntent.getBroadcast(activity, REQUEST_CODE_TOGGLE, toggleIntent, flags)
val icon = Icon.createWithResource(activity, iconRes)
icon.setTint(android.graphics.Color.WHITE)
return listOf(RemoteAction(icon, title, title, pendingIntent))
}
private fun clearPendingPictureInPictureExitCheck() {
pendingPictureInPictureExitCheck?.let(mainHandler::removeCallbacks)
pendingPictureInPictureExitCheck = null
}
private const val REQUEST_CODE_TOGGLE = 1
private const val MaxPictureInPictureAspectRatio = 2.39
private const val MinPictureInPictureAspectRatio = 1.0 / MaxPictureInPictureAspectRatio
private val pipPlayLabel: String by lazy { runBlocking { getString(Res.string.action_play) } }
private val pipPauseLabel: String by lazy { runBlocking { getString(Res.string.compose_action_pause) } }
}
private const val MaxPictureInPictureAspectRatio = 2.39
private const val MinPictureInPictureAspectRatio = 1.0 / MaxPictureInPictureAspectRatio
internal class PipRemoteActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (context == null) return
if (intent?.action != PIP_ACTION_TOGGLE_PLAY_PAUSE) return
PlayerPictureInPictureManager.toggleViaRemoteAction(context)
}
companion object {
fun register(context: Context): PipRemoteActionReceiver {
val filter = IntentFilter(PIP_ACTION_TOGGLE_PLAY_PAUSE)
val receiver = PipRemoteActionReceiver()
androidx.core.content.ContextCompat.registerReceiver(
context,
receiver,
filter,
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED
)
return receiver
}
}
}

View file

@ -5,15 +5,21 @@ import android.content.Context
import android.content.ContextWrapper
import android.content.pm.ActivityInfo
import android.media.AudioManager
import androidx.activity.ComponentActivity
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.util.Consumer
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
@ -57,7 +63,7 @@ actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) {
@Composable
actual fun ManagePlayerPictureInPicture(
isPlaying: Boolean,
playerSize: IntSize,
videoSize: IntSize,
) {
val activity = LocalContext.current.findActivity() ?: return
@ -72,11 +78,30 @@ actual fun ManagePlayerPictureInPicture(
activity = activity,
isActive = true,
isPlaying = isPlaying,
playerSize = playerSize,
videoSize = videoSize,
)
}
}
@Composable
actual fun rememberIsInPictureInPicture(): Boolean {
val context = LocalContext.current
val activity = context.findActivity() ?: return false
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
val componentActivity = activity as? ComponentActivity ?: return false
var pipState by remember(activity) { mutableStateOf(componentActivity.isInPictureInPictureMode) }
DisposableEffect(componentActivity) {
val listener = Consumer<PictureInPictureModeChangedInfo> { info ->
pipState = info.isInPictureInPictureMode
}
componentActivity.addOnPictureInPictureModeChangedListener(listener)
onDispose {
componentActivity.removeOnPictureInPictureModeChangedListener(listener)
}
}
return pipState
}
@Composable
actual fun rememberPlayerGestureController(): PlayerGestureController? {
val context = LocalContext.current

View file

@ -217,6 +217,8 @@ data class PlayerPlaybackSnapshot(
val positionMs: Long = 0L,
val bufferedPositionMs: Long = 0L,
val playbackSpeed: Float = 1f,
val videoWidth: Int = 0,
val videoHeight: Int = 0,
)
data class PlayerNowPlayingInfo(

View file

@ -24,8 +24,11 @@ expect fun EnterImmersivePlayerMode(keepScreenAwake: Boolean)
@Composable
expect fun ManagePlayerPictureInPicture(
isPlaying: Boolean,
playerSize: IntSize,
videoSize: IntSize,
)
@Composable
expect fun rememberIsInPictureInPicture(): Boolean
@Composable
expect fun rememberPlayerGestureController(): PlayerGestureController?

View file

@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.IntSize
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository
@ -138,7 +139,10 @@ internal fun PlayerScreenContent(args: PlayerScreenArgs) {
EnterImmersivePlayerMode(keepScreenAwake = keepScreenAwake)
ManagePlayerPictureInPicture(
isPlaying = runtime.playbackSnapshot.isPlaying,
playerSize = runtime.layoutSize,
videoSize = IntSize(
runtime.playbackSnapshot.videoWidth,
runtime.playbackSnapshot.videoHeight,
),
)
runtime.BindPlayerRuntimeEffects()
runtime.RenderPlayerRuntimeUi()

View file

@ -20,6 +20,7 @@ import nuvio.composeapp.generated.resources.*
@Composable
internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
val runtime = this
val isInPip = rememberIsInPictureInPicture()
val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs
val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null
val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback
@ -188,8 +189,9 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
@Composable
private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, isEpisode: Boolean) {
val isInPip = rememberIsInPictureInPicture()
AnimatedVisibility(
visible = (controlsVisible || showParentalGuide) && !playerControlsLocked,
visible = (controlsVisible || showParentalGuide) && !playerControlsLocked && !isInPip,
enter = fadeIn(),
exit = fadeOut(),
) {

View file

@ -48,9 +48,12 @@ actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) {
@Composable
actual fun ManagePlayerPictureInPicture(
isPlaying: Boolean,
playerSize: IntSize,
videoSize: IntSize,
) = Unit
@Composable
actual fun rememberIsInPictureInPicture(): Boolean = false
@Composable
actual fun rememberPlayerGestureController(): PlayerGestureController? {
val controller = remember { IOSPlayerGestureController() }