mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
feat: add PendingSeekTracker to manage seek requests and improve playback accuracy
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user