Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/nightly-notes/2026-11-07-feat-pip-next-button.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 25 additions & 5 deletions app/src/main/java/io/github/aedev/flow/player/PipActions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<PipActionSpec> {
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)
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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()
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -131,17 +142,27 @@ 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(
isPlaying = isPlaying,
autoPipEnabled = pipPreferences.autoPipEnabled,
isBackgroundPlaybackMode = isBackgroundPlaybackMode,
videoAspectRatio = videoAspectRatio,
hasNext = hasNext,
activity = activity,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
34 changes: 32 additions & 2 deletions app/src/test/java/io/github/aedev/flow/player/PipActionsTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 })
}
}
Loading