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 scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val pendingSeekTracker = PendingSeekTracker()
private var currentMediaContext: MediaContext? = null private var currentMediaContext: MediaContext? = null
@@ -97,6 +98,7 @@ class PlayerManager @Inject constructor(
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
pendingSeekTracker.clear()
refreshMetadata(mediaItem) refreshMetadata(mediaItem)
refreshQueue() refreshQueue()
} }
@@ -124,6 +126,7 @@ class PlayerManager @Inject constructor(
fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) { fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
currentMediaContext = mediaContext currentMediaContext = mediaContext
pendingSeekTracker.clear()
if (startPositionMs != null) { if (startPositionMs != null) {
player.setMediaItem(mediaItem, startPositionMs) player.setMediaItem(mediaItem, startPositionMs)
} else { } else {
@@ -139,6 +142,7 @@ class PlayerManager @Inject constructor(
fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) { fun replaceCurrentMediaItem(mediaItem: MediaItem, mediaContext: MediaContext? = null, startPositionMs: Long? = null) {
currentMediaContext = mediaContext currentMediaContext = mediaContext
pendingSeekTracker.clear()
val currentIndex = player.currentMediaItemIndex.takeIf { it != C.INDEX_UNSET } ?: run { val currentIndex = player.currentMediaItemIndex.takeIf { it != C.INDEX_UNSET } ?: run {
play(mediaItem, mediaContext, startPositionMs) play(mediaItem, mediaContext, startPositionMs)
return return
@@ -167,16 +171,17 @@ class PlayerManager @Inject constructor(
} }
fun seekTo(positionMs: Long) { fun seekTo(positionMs: Long) {
player.seekTo(positionMs) requestSeek(positionMs)
} }
fun seekBy(deltaMs: Long) { fun seekBy(deltaMs: Long) {
val target = (player.currentPosition + deltaMs).coerceAtLeast(0L) val basePositionMs = pendingSeekTracker.currentPosition(player.currentPosition)
seekTo(target) requestSeek(basePositionMs + deltaMs, basePositionMs)
} }
fun seekToLiveEdge() { fun seekToLiveEdge() {
if (player.isCurrentMediaItemLive) { if (player.isCurrentMediaItemLive) {
pendingSeekTracker.clear()
player.seekToDefaultPosition() player.seekToDefaultPosition()
player.play() player.play()
} }
@@ -184,12 +189,14 @@ class PlayerManager @Inject constructor(
fun next() { fun next() {
if (player.hasNextMediaItem()) { if (player.hasNextMediaItem()) {
pendingSeekTracker.clear()
player.seekToNextMediaItem() player.seekToNextMediaItem()
} }
} }
fun previous() { fun previous() {
if (player.hasPreviousMediaItem()) { if (player.hasPreviousMediaItem()) {
pendingSeekTracker.clear()
player.seekToPreviousMediaItem() player.seekToPreviousMediaItem()
} }
} }
@@ -251,6 +258,7 @@ class PlayerManager @Inject constructor(
val items = _queue.value val items = _queue.value
val targetIndex = items.indexOfFirst { it.id == id } val targetIndex = items.indexOfFirst { it.id == id }
if (targetIndex >= 0) { if (targetIndex >= 0) {
pendingSeekTracker.clear()
player.seekToDefaultPosition(targetIndex) player.seekToDefaultPosition(targetIndex)
player.playWhenReady = true player.playWhenReady = true
refreshQueue() refreshQueue()
@@ -265,12 +273,31 @@ class PlayerManager @Inject constructor(
val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs
return PlaybackProgressSnapshot( return PlaybackProgressSnapshot(
durationMs = duration, durationMs = duration,
positionMs = player.currentPosition, positionMs = pendingSeekTracker.currentPosition(player.currentPosition),
bufferedMs = player.bufferedPosition, bufferedMs = player.bufferedPosition,
isLive = player.isCurrentMediaItemLive 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() { private suspend fun applyTrackPreferences() {
val context = currentMediaContext ?: return val context = currentMediaContext ?: return
val preferences = trackPreferencesRepository.getMediaPreferences(context.preferenceKey).firstOrNull() ?: 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))
}
}