From cca75d8a5afc337e1673fc6fe22c7727de31f9a3 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Tue, 7 Apr 2026 15:35:14 +0200 Subject: [PATCH] feat: add PendingSeekTracker to manage seek requests and improve playback accuracy --- .../core/player/manager/PendingSeekTracker.kt | 43 +++++++++++++++ .../core/player/manager/PlayerManager.kt | 35 ++++++++++-- .../player/manager/PendingSeekTrackerTest.kt | 55 +++++++++++++++++++ 3 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 core/player/src/main/java/hu/bbara/purefin/core/player/manager/PendingSeekTracker.kt create mode 100644 core/player/src/test/java/hu/bbara/purefin/core/player/manager/PendingSeekTrackerTest.kt diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PendingSeekTracker.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PendingSeekTracker.kt new file mode 100644 index 00000000..d3c3ebd6 --- /dev/null +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PendingSeekTracker.kt @@ -0,0 +1,43 @@ +package hu.bbara.purefin.core.player.manager + +import kotlin.math.abs + +internal class PendingSeekTracker( + private val settleToleranceMs: Long = 500L +) { + private var pendingSeek: PendingSeek? = null + + fun currentPosition(playerPositionMs: Long): Long { + val pending = pendingSeek ?: return playerPositionMs + if (pending.isSatisfiedBy(playerPositionMs, settleToleranceMs)) { + pendingSeek = null + return playerPositionMs + } + return pending.targetPositionMs + } + + fun recordSeek(basePositionMs: Long, targetPositionMs: Long): Long { + val target = targetPositionMs.coerceAtLeast(0L) + pendingSeek = PendingSeek( + targetPositionMs = target, + direction = target.compareTo(basePositionMs) + ) + return target + } + + fun clear() { + pendingSeek = null + } + + private data class PendingSeek( + val targetPositionMs: Long, + val direction: Int + ) { + fun isSatisfiedBy(playerPositionMs: Long, settleToleranceMs: Long): Boolean = + when { + direction > 0 -> playerPositionMs >= targetPositionMs - settleToleranceMs + direction < 0 -> playerPositionMs <= targetPositionMs + settleToleranceMs + else -> abs(playerPositionMs - targetPositionMs) <= settleToleranceMs + } + } +} diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt index 8bfeb5c7..9fb051f6 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/manager/PlayerManager.kt @@ -45,6 +45,7 @@ class PlayerManager @Inject constructor( ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val pendingSeekTracker = PendingSeekTracker() private var currentMediaContext: MediaContext? = null @@ -97,6 +98,7 @@ class PlayerManager @Inject constructor( } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + pendingSeekTracker.clear() refreshMetadata(mediaItem) refreshQueue() } @@ -124,6 +126,7 @@ class PlayerManager @Inject constructor( fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) { currentMediaContext = mediaContext + pendingSeekTracker.clear() if (startPositionMs != null) { player.setMediaItem(mediaItem, startPositionMs) } else { @@ -139,6 +142,7 @@ class PlayerManager @Inject constructor( fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) { currentMediaContext = mediaContext + pendingSeekTracker.clear() val currentIndex = player.currentMediaItemIndex.takeIf { it != C.INDEX_UNSET } ?: run { play(mediaItem, mediaContext, startPositionMs) return @@ -167,16 +171,17 @@ class PlayerManager @Inject constructor( } fun seekTo(positionMs: Long) { - player.seekTo(positionMs) + requestSeek(positionMs) } fun seekBy(deltaMs: Long) { - val target = (player.currentPosition + deltaMs).coerceAtLeast(0L) - seekTo(target) + val basePositionMs = pendingSeekTracker.currentPosition(player.currentPosition) + requestSeek(basePositionMs + deltaMs, basePositionMs) } fun seekToLiveEdge() { if (player.isCurrentMediaItemLive) { + pendingSeekTracker.clear() player.seekToDefaultPosition() player.play() } @@ -184,12 +189,14 @@ class PlayerManager @Inject constructor( fun next() { if (player.hasNextMediaItem()) { + pendingSeekTracker.clear() player.seekToNextMediaItem() } } fun previous() { if (player.hasPreviousMediaItem()) { + pendingSeekTracker.clear() player.seekToPreviousMediaItem() } } @@ -251,6 +258,7 @@ class PlayerManager @Inject constructor( val items = _queue.value val targetIndex = items.indexOfFirst { it.id == id } if (targetIndex >= 0) { + pendingSeekTracker.clear() player.seekToDefaultPosition(targetIndex) player.playWhenReady = true refreshQueue() @@ -265,12 +273,31 @@ class PlayerManager @Inject constructor( val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs return PlaybackProgressSnapshot( durationMs = duration, - positionMs = player.currentPosition, + positionMs = pendingSeekTracker.currentPosition(player.currentPosition), bufferedMs = player.bufferedPosition, isLive = player.isCurrentMediaItemLive ) } + private fun requestSeek(positionMs: Long, basePositionMs: Long = pendingSeekTracker.currentPosition(player.currentPosition)) { + val targetPositionMs = clampSeekPosition(positionMs) + pendingSeekTracker.recordSeek(basePositionMs = basePositionMs, targetPositionMs = targetPositionMs) + _progress.update { + it.copy( + durationMs = player.duration.takeIf { value -> value > 0L } ?: it.durationMs, + positionMs = targetPositionMs, + bufferedMs = player.bufferedPosition, + isLive = player.isCurrentMediaItemLive + ) + } + player.seekTo(targetPositionMs) + } + + private fun clampSeekPosition(positionMs: Long): Long { + val durationMs = player.duration.takeIf { it > 0L } ?: _progress.value.durationMs.takeIf { it > 0L } + return durationMs?.let { positionMs.coerceIn(0L, it) } ?: positionMs.coerceAtLeast(0L) + } + private suspend fun applyTrackPreferences() { val context = currentMediaContext ?: return val preferences = trackPreferencesRepository.getMediaPreferences(context.preferenceKey).firstOrNull() ?: return diff --git a/core/player/src/test/java/hu/bbara/purefin/core/player/manager/PendingSeekTrackerTest.kt b/core/player/src/test/java/hu/bbara/purefin/core/player/manager/PendingSeekTrackerTest.kt new file mode 100644 index 00000000..f452cae8 --- /dev/null +++ b/core/player/src/test/java/hu/bbara/purefin/core/player/manager/PendingSeekTrackerTest.kt @@ -0,0 +1,55 @@ +package hu.bbara.purefin.core.player.manager + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PendingSeekTrackerTest { + + @Test + fun repeatedForwardSeeksAccumulateWhilePlayerPositionIsStale() { + val tracker = PendingSeekTracker() + + val firstBase = tracker.currentPosition(playerPositionMs = 30_000L) + tracker.recordSeek(basePositionMs = firstBase, targetPositionMs = 40_000L) + + val secondBase = tracker.currentPosition(playerPositionMs = 30_000L) + tracker.recordSeek(basePositionMs = secondBase, targetPositionMs = secondBase + 10_000L) + + assertEquals(50_000L, tracker.currentPosition(playerPositionMs = 30_000L)) + } + + @Test + fun repeatedBackwardSeeksAccumulateWhilePlayerPositionIsStale() { + val tracker = PendingSeekTracker() + + val firstBase = tracker.currentPosition(playerPositionMs = 30_000L) + tracker.recordSeek(basePositionMs = firstBase, targetPositionMs = 20_000L) + + val secondBase = tracker.currentPosition(playerPositionMs = 30_000L) + tracker.recordSeek(basePositionMs = secondBase, targetPositionMs = secondBase - 10_000L) + + assertEquals(10_000L, tracker.currentPosition(playerPositionMs = 30_000L)) + } + + @Test + fun pendingForwardSeekClearsOncePlayerCatchesUp() { + val tracker = PendingSeekTracker() + + tracker.recordSeek(basePositionMs = 30_000L, targetPositionMs = 40_000L) + + assertEquals(40_000L, tracker.currentPosition(playerPositionMs = 30_000L)) + assertEquals(39_700L, tracker.currentPosition(playerPositionMs = 39_700L)) + assertEquals(41_000L, tracker.currentPosition(playerPositionMs = 41_000L)) + } + + @Test + fun pendingBackwardSeekClearsOncePlayerCatchesUp() { + val tracker = PendingSeekTracker() + + tracker.recordSeek(basePositionMs = 30_000L, targetPositionMs = 20_000L) + + assertEquals(20_000L, tracker.currentPosition(playerPositionMs = 30_000L)) + assertEquals(20_300L, tracker.currentPosition(playerPositionMs = 20_300L)) + assertEquals(19_000L, tracker.currentPosition(playerPositionMs = 19_000L)) + } +}