diff --git a/.github/nightly-notes/2026-11-07-feat-pip-next-button.md b/.github/nightly-notes/2026-11-07-feat-pip-next-button.md new file mode 100644 index 000000000..31039a904 --- /dev/null +++ b/.github/nightly-notes/2026-11-07-feat-pip-next-button.md @@ -0,0 +1,5 @@ +## New +- The video picture-in-picture window now has a Next button, beside Listen in background and Play or Pause (#1824) + +## Fixed +- Next no longer shows greyed out in the Shorts picture-in-picture window on Android 17 diff --git a/app/src/main/java/io/github/aedev/flow/player/PictureInPictureHelper.kt b/app/src/main/java/io/github/aedev/flow/player/PictureInPictureHelper.kt index b81c76fa7..7ec4ee72e 100644 --- a/app/src/main/java/io/github/aedev/flow/player/PictureInPictureHelper.kt +++ b/app/src/main/java/io/github/aedev/flow/player/PictureInPictureHelper.kt @@ -86,6 +86,15 @@ object PictureInPictureHelper { shortsNavigation = navigation } + /** Published by the video player so every way into PiP shows Next as it stands right now. */ + @Volatile + var videoHasNext: Boolean = false + private set + + internal fun setVideoHasNext(hasNext: Boolean) { + videoHasNext = hasNext + } + @Volatile var currentVideoAspectRatio: Float = DEFAULT_VIDEO_ASPECT_RATIO private set @@ -302,7 +311,9 @@ object PictureInPictureHelper { val aspectRatio = Rational((safeAspectRatio * 1_000).roundToInt(), 1_000) val maxActions = (context as? Activity)?.maxNumPictureInPictureActions ?: Int.MAX_VALUE - val actions = pipActionSpecs(isPlaying, navigation, maxActions, offerBackground).map { createRemoteAction(context, it) } + val actions = + pipActionSpecs(isPlaying, navigation, maxActions, offerBackground, videoHasNext) + .map { createRemoteAction(context, it) } val builder = PictureInPictureParams diff --git a/app/src/main/java/io/github/aedev/flow/player/PipActions.kt b/app/src/main/java/io/github/aedev/flow/player/PipActions.kt index b191edec4..f31536ee7 100644 --- a/app/src/main/java/io/github/aedev/flow/player/PipActions.kt +++ b/app/src/main/java/io/github/aedev/flow/player/PipActions.kt @@ -18,20 +18,34 @@ private const val BACKGROUND_LAYOUT_ACTION_COUNT = 2 /** * The window's buttons in display order. Skip buttons need a queue and room for all three; a video - * without a queue can offer listening in the background beside play or pause. + * without a queue can offer listening in the background beside play or pause, then Next if there is + * room. With nothing to play next that button greys out rather than going, so the others stay put. */ internal fun pipActionSpecs( isPlaying: Boolean, navigation: PipQueueNavigation?, maxActions: Int, offerBackground: Boolean = false, + videoHasNext: Boolean = false, ): List { val playPause = PipActionSpec(if (isPlaying) PipControl.PAUSE else PipControl.PLAY) if (navigation == null) { - return if (offerBackground && maxActions >= BACKGROUND_LAYOUT_ACTION_COUNT) { - listOf(PipActionSpec(PipControl.BACKGROUND), playPause) - } else { - listOf(playPause) + return when { + !offerBackground || maxActions < BACKGROUND_LAYOUT_ACTION_COUNT -> { + listOf(playPause) + } + + maxActions < SKIP_LAYOUT_ACTION_COUNT -> { + listOf(PipActionSpec(PipControl.BACKGROUND), playPause) + } + + else -> { + listOf( + PipActionSpec(PipControl.BACKGROUND), + playPause, + PipActionSpec(PipControl.NEXT, enabled = videoHasNext), + ) + } } } if (maxActions < SKIP_LAYOUT_ACTION_COUNT) return listOf(playPause) @@ -41,3 +55,9 @@ internal fun pipActionSpecs( PipActionSpec(PipControl.NEXT, enabled = navigation.hasNext), ) } + +/** + * The video and Shorts receivers both hear every action. While Shorts owns the window its Next is + * for the reel, and a video kept composed behind it must not skip too. + */ +internal fun videoHandlesPipNext(shortsNavigation: PipQueueNavigation?): Boolean = shortsNavigation == null diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/player/VideoPlayerHost.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/player/VideoPlayerHost.kt index 0969bb5be..f9e134bcd 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/player/VideoPlayerHost.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/player/VideoPlayerHost.kt @@ -280,11 +280,8 @@ fun VideoPlayerHost( isBackgroundPlaybackMode = playerUiState.isBackgroundPlaybackMode, videoAspectRatio = videoAspectRatio, pipPreferences = pipPreferences, - // A PiP window can't be sent back; finishing closes it and onDestroy keeps the background audio. - onListenInBackground = { - playerViewModel.startBackgroundPlayback() - activity.finish() - }, + hasNext = playerState.hasNext || playerUiState.relatedVideos.isNotEmpty(), + playerViewModel = playerViewModel, ) // A PiP window shows only the video from its first frame and never turns (#1671). diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/player/effects/PipModeHandler.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/player/effects/PipModeHandler.kt index daeb71bc1..964bd7687 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/player/effects/PipModeHandler.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/player/effects/PipModeHandler.kt @@ -12,6 +12,8 @@ import io.github.aedev.flow.player.BackgroundPlaybackPolicy import io.github.aedev.flow.player.EnhancedPlayerManager import io.github.aedev.flow.player.GlobalPlayerState import io.github.aedev.flow.player.PictureInPictureHelper +import io.github.aedev.flow.player.videoHandlesPipNext +import io.github.aedev.flow.ui.screens.player.VideoPlayerViewModel private const val TAG = "PipModeHandler" @@ -21,14 +23,17 @@ private const val TAG = "PipModeHandler" @Composable private fun PipBroadcastReceiverEffect( context: Context, + onNext: () -> Unit, onBackground: () -> Unit, ) { + val currentOnNext by rememberUpdatedState(onNext) val currentOnBackground by rememberUpdatedState(onBackground) DisposableEffect(Unit) { val receiver = PictureInPictureHelper.createPipActionReceiver( onPlay = { EnhancedPlayerManager.getInstance().play() }, onPause = { EnhancedPlayerManager.getInstance().pause() }, + onNext = { if (videoHandlesPipNext(PictureInPictureHelper.shortsNavigation)) currentOnNext() }, onClose = { GlobalPlayerState.requestDismiss() EnhancedPlayerManager.getInstance().stop() @@ -63,9 +68,14 @@ private fun PipParamsUpdateEffect( autoPipEnabled: Boolean, isBackgroundPlaybackMode: Boolean, videoAspectRatio: Float, + hasNext: Boolean, activity: Activity?, ) { - LaunchedEffect(isPlaying, autoPipEnabled, isBackgroundPlaybackMode, videoAspectRatio) { + LaunchedEffect(isPlaying, autoPipEnabled, isBackgroundPlaybackMode, videoAspectRatio, hasNext) { + val nextChanged = PictureInPictureHelper.videoHasNext != hasNext + PictureInPictureHelper.setVideoHasNext(hasNext) + // Related videos landing for a video behind Shorts must not overwrite the Shorts window's buttons. + if (nextChanged && !videoHandlesPipNext(PictureInPictureHelper.shortsNavigation)) return@LaunchedEffect if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && activity != null) { val autoEnterEnabled = BackgroundPlaybackPolicy.shouldEnterAutoPip( @@ -85,6 +95,7 @@ private fun PipParamsUpdateEffect( DisposableEffect(activity) { onDispose { + PictureInPictureHelper.setVideoHasNext(false) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && activity != null) { PictureInPictureHelper.updatePipParams( activity = activity, @@ -131,10 +142,19 @@ internal fun SetupPipEffects( isBackgroundPlaybackMode: Boolean, videoAspectRatio: Float, pipPreferences: PipPreferences, - onListenInBackground: () -> Unit, + hasNext: Boolean, + playerViewModel: VideoPlayerViewModel, ) { // Register broadcast receiver - PipBroadcastReceiverEffect(context, onListenInBackground) + PipBroadcastReceiverEffect( + context = context, + onNext = { playerViewModel.playNext() }, + // A PiP window can't be sent back; finishing closes it and onDestroy keeps the background audio. + onBackground = { + playerViewModel.startBackgroundPlayback() + activity?.finish() + }, + ) // Update PiP params PipParamsUpdateEffect( @@ -142,6 +162,7 @@ internal fun SetupPipEffects( autoPipEnabled = pipPreferences.autoPipEnabled, isBackgroundPlaybackMode = isBackgroundPlaybackMode, videoAspectRatio = videoAspectRatio, + hasNext = hasNext, activity = activity, ) } diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsPipEffects.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsPipEffects.kt index f4d3604c5..f1a6d8ad6 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsPipEffects.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsPipEffects.kt @@ -55,8 +55,10 @@ internal fun ShortsPipActionEffect( if (target != pagerState.targetPage) scope.launch { pagerState.animateScrollToPage(target) } } - // The settled reel starts playing on its own, so the window follows it with a pause button. - LaunchedEffect(pagerState.settledPage, navigation) { + // The settled reel starts playing on its own, so the window follows it with a pause button. Setting + // the params again once the window opens matters too: the Android 17 PiP menu can grey the skip + // buttons the wrong way round from the params the window entered with, until they are set again. + LaunchedEffect(pagerState.settledPage, navigation, isInPip) { PictureInPictureHelper.setShortsNavigation(navigation) if (inPip) restateWindow(isPlaying = true) } diff --git a/app/src/test/java/io/github/aedev/flow/player/PipActionsTest.kt b/app/src/test/java/io/github/aedev/flow/player/PipActionsTest.kt index 60fa39341..0553fede3 100644 --- a/app/src/test/java/io/github/aedev/flow/player/PipActionsTest.kt +++ b/app/src/test/java/io/github/aedev/flow/player/PipActionsTest.kt @@ -36,12 +36,40 @@ class PipActionsTest { } @Test - fun `a video offers listening in the background before play or pause`() { - val specs = pipActionSpecs(isPlaying = true, navigation = null, maxActions = 3, offerBackground = true) + fun `a video window has listen in the background, play or pause, then next`() { + val specs = pipActionSpecs(isPlaying = true, navigation = null, maxActions = 3, offerBackground = true, videoHasNext = true) + + assertEquals( + listOf( + PipActionSpec(PipControl.BACKGROUND), + PipActionSpec(PipControl.PAUSE), + PipActionSpec(PipControl.NEXT), + ), + specs, + ) + } + + @Test + fun `a video with nothing to play next greys the button and keeps the layout`() { + val specs = pipActionSpecs(isPlaying = false, navigation = null, maxActions = 3, offerBackground = true, videoHasNext = false) + + assertEquals(listOf(PipControl.BACKGROUND, PipControl.PLAY, PipControl.NEXT), specs.map { it.control }) + assertEquals(listOf(true, true, false), specs.map { it.enabled }) + } + + @Test + fun `a video window with room for two buttons drops next first`() { + val specs = pipActionSpecs(isPlaying = true, navigation = null, maxActions = 2, offerBackground = true, videoHasNext = true) assertEquals(listOf(PipControl.BACKGROUND, PipControl.PAUSE), specs.map { it.control }) } + @Test + fun `next goes to the video unless Shorts owns the window`() { + assertEquals(true, videoHandlesPipNext(shortsNavigation = null)) + assertEquals(false, videoHandlesPipNext(PipQueueNavigation(hasPrevious = true, hasNext = true))) + } + @Test fun `a window with room for one button keeps only play or pause`() { val specs = pipActionSpecs(isPlaying = false, navigation = null, maxActions = 1, offerBackground = true) @@ -57,8 +85,10 @@ class PipActionsTest { navigation = PipQueueNavigation(hasPrevious = true, hasNext = true), maxActions = 3, offerBackground = true, + videoHasNext = false, ) assertEquals(listOf(PipControl.PREVIOUS, PipControl.PAUSE, PipControl.NEXT), specs.map { it.control }) + assertEquals(listOf(true, true, true), specs.map { it.enabled }) } }