From ea5dfc2d67e5d8b09b3e563e397a3794338811f2 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:56:32 +0200 Subject: [PATCH 1/4] feat(pip): add a Next button to the video window --- .../flow/player/PictureInPictureHelper.kt | 13 ++++++++- .../io/github/aedev/flow/player/PipActions.kt | 24 ++++++++++++---- .../flow/ui/screens/player/VideoPlayerHost.kt | 7 ++--- .../screens/player/effects/PipModeHandler.kt | 23 +++++++++++++-- .../aedev/flow/player/PipActionsTest.kt | 28 +++++++++++++++++-- 5 files changed, 79 insertions(+), 16 deletions(-) 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..9d4648139 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) 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..ed191d6ec 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,7 @@ 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.ui.screens.player.VideoPlayerViewModel private const val TAG = "PipModeHandler" @@ -21,14 +22,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 = { currentOnNext() }, onClose = { GlobalPlayerState.requestDismiss() EnhancedPlayerManager.getInstance().stop() @@ -63,9 +67,11 @@ private fun PipParamsUpdateEffect( autoPipEnabled: Boolean, isBackgroundPlaybackMode: Boolean, videoAspectRatio: Float, + hasNext: Boolean, activity: Activity?, ) { - LaunchedEffect(isPlaying, autoPipEnabled, isBackgroundPlaybackMode, videoAspectRatio) { + LaunchedEffect(isPlaying, autoPipEnabled, isBackgroundPlaybackMode, videoAspectRatio, hasNext) { + PictureInPictureHelper.setVideoHasNext(hasNext) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && activity != null) { val autoEnterEnabled = BackgroundPlaybackPolicy.shouldEnterAutoPip( @@ -85,6 +91,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 +138,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 +158,7 @@ internal fun SetupPipEffects( autoPipEnabled = pipPreferences.autoPipEnabled, isBackgroundPlaybackMode = isBackgroundPlaybackMode, videoAspectRatio = videoAspectRatio, + hasNext = hasNext, activity = activity, ) } 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..b0b9e1bd7 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,8 +36,30 @@ 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 }) } @@ -57,8 +79,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 }) } } From a8971d00d108bd669952e99bca754d3d06f4e818 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:56:32 +0200 Subject: [PATCH 2/4] fix(pip): leave a hidden video alone when Shorts skips --- app/src/main/java/io/github/aedev/flow/player/PipActions.kt | 6 ++++++ .../aedev/flow/ui/screens/player/effects/PipModeHandler.kt | 6 +++++- .../test/java/io/github/aedev/flow/player/PipActionsTest.kt | 6 ++++++ 3 files changed, 17 insertions(+), 1 deletion(-) 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 9d4648139..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 @@ -55,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/effects/PipModeHandler.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/player/effects/PipModeHandler.kt index ed191d6ec..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,7 @@ 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" @@ -32,7 +33,7 @@ private fun PipBroadcastReceiverEffect( PictureInPictureHelper.createPipActionReceiver( onPlay = { EnhancedPlayerManager.getInstance().play() }, onPause = { EnhancedPlayerManager.getInstance().pause() }, - onNext = { currentOnNext() }, + onNext = { if (videoHandlesPipNext(PictureInPictureHelper.shortsNavigation)) currentOnNext() }, onClose = { GlobalPlayerState.requestDismiss() EnhancedPlayerManager.getInstance().stop() @@ -71,7 +72,10 @@ private fun PipParamsUpdateEffect( activity: Activity?, ) { 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( 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 b0b9e1bd7..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 @@ -64,6 +64,12 @@ class PipActionsTest { 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) From 43c930adb4014d264360d03511f001ee46a798f8 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:56:32 +0200 Subject: [PATCH 3/4] docs(nightly): note the Next button in picture-in-picture --- .github/nightly-notes/2026-11-07-feat-pip-next-button.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/nightly-notes/2026-11-07-feat-pip-next-button.md 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..9897d1216 --- /dev/null +++ b/.github/nightly-notes/2026-11-07-feat-pip-next-button.md @@ -0,0 +1,2 @@ +## New +- The video picture-in-picture window now has a Next button, beside Listen in background and Play or Pause (#1824) From 9c03b1cb9e6278cc00a8e4d444863efc31c245fb Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:55:21 +0200 Subject: [PATCH 4/4] fix(pip): set the Shorts window's buttons again once it opens --- .github/nightly-notes/2026-11-07-feat-pip-next-button.md | 3 +++ .../github/aedev/flow/ui/screens/shorts/ShortsPipEffects.kt | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) 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 index 9897d1216..31039a904 100644 --- a/.github/nightly-notes/2026-11-07-feat-pip-next-button.md +++ b/.github/nightly-notes/2026-11-07-feat-pip-next-button.md @@ -1,2 +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/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) }