mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
feat: Allow repeated seeking in the tv ui
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user