feat: Allow repeated seeking in the tv ui

This commit is contained in:
2026-04-08 20:02:27 +02:00
parent cf08db5034
commit 98c936aea6

View File

@@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.abs
/** /**
* Encapsulates the Media3 [Player] wiring and exposes reactive updates for the UI layer. * Encapsulates the Media3 [Player] wiring and exposes reactive updates for the UI layer.
@@ -42,10 +43,14 @@ class PlayerManager @Inject constructor(
private val trackPreferencesRepository: TrackPreferencesRepository, private val trackPreferencesRepository: TrackPreferencesRepository,
private val trackMatcher: TrackMatcher private val trackMatcher: TrackMatcher
) { ) {
companion object {
private const val SEEK_SETTLE_TOLERANCE_MS = 750L
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var currentMediaContext: MediaContext? = null private var currentMediaContext: MediaContext? = null
private var pendingSeekPositionMs: Long? = null
private val _playbackState = MutableStateFlow(PlaybackStateSnapshot()) private val _playbackState = MutableStateFlow(PlaybackStateSnapshot())
val playbackState: StateFlow<PlaybackStateSnapshot> = _playbackState.asStateFlow() val playbackState: StateFlow<PlaybackStateSnapshot> = _playbackState.asStateFlow()
@@ -92,9 +97,18 @@ class PlayerManager @Inject constructor(
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
clearPendingSeek()
refreshMetadata(mediaItem) refreshMetadata(mediaItem)
refreshQueue() refreshQueue()
} }
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
syncPendingSeek(newPosition.positionMs)
}
} }
init { init {
@@ -107,6 +121,7 @@ class PlayerManager @Inject constructor(
fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null) { fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null) {
currentMediaContext = mediaContext currentMediaContext = mediaContext
clearPendingSeek()
player.setMediaItem(mediaItem) player.setMediaItem(mediaItem)
player.prepare() player.prepare()
player.playWhenReady = true player.playWhenReady = true
@@ -125,15 +140,26 @@ class PlayerManager @Inject constructor(
} }
fun seekTo(positionMs: Long) { fun seekTo(positionMs: Long) {
player.seekTo(positionMs) val target = clampSeekPosition(positionMs)
pendingSeekPositionMs = target
_progress.update { progress ->
progress.copy(
durationMs = resolveDurationMs(),
positionMs = target,
isLive = player.isCurrentMediaItemLive
)
}
player.seekTo(target)
} }
fun seekBy(deltaMs: Long) { fun seekBy(deltaMs: Long) {
val target = (player.currentPosition + deltaMs).coerceAtLeast(0L) val basePosition = pendingSeekPositionMs ?: player.currentPosition
val target = clampSeekPosition(basePosition + deltaMs)
seekTo(target) seekTo(target)
} }
fun seekToLiveEdge() { fun seekToLiveEdge() {
clearPendingSeek()
if (player.isCurrentMediaItemLive) { if (player.isCurrentMediaItemLive) {
player.seekToDefaultPosition() player.seekToDefaultPosition()
player.play() player.play()
@@ -141,12 +167,14 @@ class PlayerManager @Inject constructor(
} }
fun next() { fun next() {
clearPendingSeek()
if (player.hasNextMediaItem()) { if (player.hasNextMediaItem()) {
player.seekToNextMediaItem() player.seekToNextMediaItem()
} }
} }
fun previous() { fun previous() {
clearPendingSeek()
if (player.hasPreviousMediaItem()) { if (player.hasPreviousMediaItem()) {
player.seekToPreviousMediaItem() player.seekToPreviousMediaItem()
} }
@@ -201,6 +229,7 @@ class PlayerManager @Inject constructor(
} }
fun retry() { fun retry() {
clearPendingSeek()
player.prepare() player.prepare()
player.playWhenReady = true player.playWhenReady = true
} }
@@ -209,6 +238,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) {
clearPendingSeek()
player.seekToDefaultPosition(targetIndex) player.seekToDefaultPosition(targetIndex)
player.playWhenReady = true player.playWhenReady = true
refreshQueue() refreshQueue()
@@ -281,7 +311,9 @@ class PlayerManager @Inject constructor(
scope.launch { scope.launch {
while (isActive) { while (isActive) {
val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs
val position = player.currentPosition val actualPosition = player.currentPosition
syncPendingSeek(actualPosition)
val position = pendingSeekPositionMs ?: actualPosition
val buffered = player.bufferedPosition val buffered = player.bufferedPosition
_progress.value = PlaybackProgressSnapshot( _progress.value = PlaybackProgressSnapshot(
durationMs = duration, durationMs = duration,
@@ -324,6 +356,30 @@ class PlayerManager @Inject constructor(
private fun refreshTracks(tracks: Tracks) { private fun refreshTracks(tracks: Tracks) {
_tracks.value = trackMapper.map(tracks) _tracks.value = trackMapper.map(tracks)
} }
private fun clampSeekPosition(positionMs: Long): Long {
val duration = resolveDurationMs()
return if (duration > 0) {
positionMs.coerceIn(0L, duration)
} else {
positionMs.coerceAtLeast(0L)
}
}
private fun resolveDurationMs(): Long {
return player.duration.takeIf { it > 0 } ?: _progress.value.durationMs
}
private fun clearPendingSeek() {
pendingSeekPositionMs = null
}
private fun syncPendingSeek(positionMs: Long) {
val pendingPosition = pendingSeekPositionMs ?: return
if (abs(positionMs - pendingPosition) <= SEEK_SETTLE_TOLERANCE_MS) {
clearPendingSeek()
}
}
} }
data class PlaybackStateSnapshot( data class PlaybackStateSnapshot(