feat: add PendingSeekTracker to manage seek requests and improve playback accuracy

This commit is contained in:
2026-04-07 15:35:14 +02:00
parent 59c9e31ca5
commit cca75d8a5a
3 changed files with 129 additions and 4 deletions

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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))
}
}