diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index 3a46279de2..b33afce7a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher import com.vitorpamplona.amethyst.service.playback.playerPool.positions.CurrentPlayPositionCacher import com.vitorpamplona.amethyst.service.playback.playerPool.positions.VideoViewedPositionCache +import com.vitorpamplona.amethyst.service.playback.playerPool.repeat.AutoReplayLimiter import com.vitorpamplona.amethyst.service.playback.playerPool.wake.KeepVideosPlaying @OptIn(UnstableApi::class) @@ -82,6 +83,7 @@ class ExoPlayerBuilder( ) PcmTapRegistry.bind(currentMediaItem?.mediaId, sink) addListener(AspectRatioCacher(MediaAspectRatioCache)) + addListener(AutoReplayLimiter(pause = ::pause)) addListener(KeepVideosPlaying(this)) addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/repeat/AutoReplayLimiter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/repeat/AutoReplayLimiter.kt new file mode 100644 index 0000000000..b16b4b0714 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/repeat/AutoReplayLimiter.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.playerPool.repeat + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player + +/** + * Caps how many times a video loops on its own under [Player.REPEAT_MODE_ONE]. + * + * Feed videos are configured to repeat forever (keepPlaying → REPEAT_MODE_ONE in + * PlaybackService), which keeps decoders, network and the screen busy long after the + * user stopped watching. This listener lets a video play [maxAutoPlays] full times and + * then pauses it, so continuing requires an explicit press of the play button. + * + * Each loop under REPEAT_MODE_ONE surfaces as an [onMediaItemTransition] with + * [Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT], marking one completed play. The + * transition has already seeked back to the start, so pausing there leaves the video + * on its first frame with the play button showing. Any resume — the user pressing + * play, or the feed mutex auto-playing when the video scrolls back to the center — + * grants a fresh allowance, and switching to a different media item resets it too. + */ +class AutoReplayLimiter( + val maxAutoPlays: Int = DEFAULT_MAX_AUTO_PLAYS, + val pause: () -> Unit, +) : Player.Listener { + private var playsCompleted = 0 + + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) { + playsCompleted++ + if (playsCompleted >= maxAutoPlays) { + pause() + } + } else { + playsCompleted = 0 + } + } + + override fun onPlayWhenReadyChanged( + playWhenReady: Boolean, + reason: Int, + ) { + if (playWhenReady) { + playsCompleted = 0 + } + } + + companion object { + const val DEFAULT_MAX_AUTO_PLAYS = 5 + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/repeat/AutoReplayLimiterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/repeat/AutoReplayLimiterTest.kt new file mode 100644 index 0000000000..891c0d5140 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/repeat/AutoReplayLimiterTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.playerPool.repeat + +import androidx.media3.common.Player +import org.junit.Assert.assertEquals +import org.junit.Test + +class AutoReplayLimiterTest { + private var pauseCalls = 0 + private val limiter = AutoReplayLimiter(maxAutoPlays = 5) { pauseCalls++ } + + private fun startPlaying() = limiter.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + + private fun completeOnePlay() = limiter.onMediaItemTransition(null, Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) + + private fun newMediaItem() = limiter.onMediaItemTransition(null, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) + + @Test + fun pausesWhenTheFifthPlayCompletes() { + startPlaying() + + // plays 1..4 complete: repeat transitions 1..4 start plays 2..5 + repeat(4) { completeOnePlay() } + assertEquals(0, pauseCalls) + + // play 5 completes: the 5th repeat transition would start play 6 + completeOnePlay() + assertEquals(1, pauseCalls) + } + + @Test + fun pressingPlayGrantsAFreshAllowance() { + startPlaying() + repeat(5) { completeOnePlay() } + assertEquals(1, pauseCalls) + + // user presses play again + startPlaying() + repeat(4) { completeOnePlay() } + assertEquals(1, pauseCalls) + + completeOnePlay() + assertEquals(2, pauseCalls) + } + + @Test + fun switchingMediaItemsResetsTheCount() { + startPlaying() + repeat(4) { completeOnePlay() } + + // the pooled player is handed a different video + newMediaItem() + + repeat(4) { completeOnePlay() } + assertEquals(0, pauseCalls) + + completeOnePlay() + assertEquals(1, pauseCalls) + } + + @Test + fun pausingMidPlayDoesNotResetTheCount() { + startPlaying() + repeat(3) { completeOnePlay() } + + // a pause alone (e.g. scrolled off screen) does not reset; only a resume does + limiter.onPlayWhenReadyChanged(false, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + + repeat(2) { completeOnePlay() } + assertEquals(1, pauseCalls) + } +}