feat: add skip segment functionality to player

This commit is contained in:
2026-04-26 12:21:52 +02:00
parent 9906d473c5
commit 522c8273d5
13 changed files with 263 additions and 56 deletions

View File

@@ -1,69 +1,83 @@
package hu.bbara.purefin.player.manager
import android.os.Looper
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.PlayerMessage
import hu.bbara.purefin.model.MediaSegment
import hu.bbara.purefin.model.SegmentType
import hu.bbara.purefin.player.model.SegmentStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@OptIn(UnstableApi::class)
class MediaSegmentManager(private val player: ExoPlayer) {
private var listener: MediaSegmentListener? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var mediaSegments: List<MediaSegment> = emptyList()
private var activeSegment: MediaSegment? = null
interface MediaSegmentListener {
fun onEvent(segmentType: SegmentType, status: SegmentStatus)
fun onEvent(mediaSegment: MediaSegment, status: SegmentStatus)
}
init {
startPolling()
}
@Synchronized
fun registerListener(listener: MediaSegmentListener) {
this.listener?.let {
Log.w("PlayerMessageManager", "Listener was already register")
Log.w("MediaSegmentManager", "Listener was already register")
return
}
this.listener = listener
}
fun addMediaSegments(mediaSegments: List<MediaSegment>) {
val messageTarget = PlayerMessage.Target { messageType, payload ->
SegmentType.fromValue(messageType)?.let { segmentType ->
notifyListener(segmentType, payload as SegmentStatus)
this.mediaSegments = mediaSegments
activeSegment = null
}
fun release() {
scope.cancel()
}
private fun startPolling() {
scope.launch {
while (isActive) {
updateActiveSegment()
delay(1_000)
}
}
mediaSegments.forEach { mediaSegment ->
installMediaSegment(messageTarget, mediaSegment)
}
}
private fun notifyListener(segmentType: SegmentType, status: SegmentStatus) {
private fun updateActiveSegment() {
val currentPosition = player.currentPosition
val currentSegment = mediaSegments.firstOrNull { mediaSegment ->
currentPosition >= mediaSegment.startMs && currentPosition < mediaSegment.endMs
}
val previousSegment = activeSegment
if (previousSegment?.id == currentSegment?.id) return
previousSegment?.let {
notifyListener(it, SegmentStatus.END)
}
currentSegment?.let {
notifyListener(it, SegmentStatus.START)
}
activeSegment = currentSegment
}
private fun notifyListener(mediaSegment: MediaSegment, status: SegmentStatus) {
val listener = listener
if (listener == null) {
Log.w("PlayerMessageManager", "Listener was not register therefore it cannot notify")
Log.w("MediaSegmentManager", "Listener was not register therefore it cannot notify")
return
}
listener?.onEvent(segmentType, status)
Log.d("MediaSegmentManager", "Notify listener about $mediaSegment with status $status")
listener.onEvent(mediaSegment, status)
}
private fun installMediaSegment(messageTarget: PlayerMessage.Target, mediaSegment: MediaSegment) {
player.createMessage(messageTarget)
.setType(mediaSegment.type.value)
.setPosition(mediaSegment.startMs)
.setPayload(SegmentStatus.START)
.setLooper(Looper.getMainLooper())
.setDeleteAfterDelivery(false)
.send()
player.createMessage(messageTarget)
.setType(mediaSegment.type.value)
.setPayload(SegmentStatus.END)
.setPosition(mediaSegment.endMs)
.setLooper(Looper.getMainLooper())
.setDeleteAfterDelivery(false)
.send()
}
}
}

View File

@@ -14,10 +14,12 @@ import hu.bbara.purefin.data.PlaybackReportContext
import hu.bbara.purefin.model.AudioTrackProperties
import hu.bbara.purefin.model.MediaSegment
import hu.bbara.purefin.model.PlayableMedia
import hu.bbara.purefin.model.SegmentType
import hu.bbara.purefin.model.SubtitleTrackProperties
import hu.bbara.purefin.player.model.MetadataState
import hu.bbara.purefin.player.model.PlaybackProgressSnapshot
import hu.bbara.purefin.player.model.PlaybackStateSnapshot
import hu.bbara.purefin.player.model.SegmentStatus
import hu.bbara.purefin.player.model.TrackOption
import hu.bbara.purefin.player.model.TrackType
import hu.bbara.purefin.player.preference.TrackMatcher
@@ -80,8 +82,16 @@ class PlayerManager @Inject constructor(
val metadata: StateFlow<MetadataState> = _metadata.asStateFlow()
private val _tracks = MutableStateFlow(TrackSelectionState())
val tracks: StateFlow<TrackSelectionState> = _tracks.asStateFlow()
private val _activeSkippableSegment = MutableStateFlow<MediaSegment?>(null)
val activeSkippableSegment: StateFlow<MediaSegment?> = _activeSkippableSegment.asStateFlow()
private val mediaSegmentListener = object : MediaSegmentManager.MediaSegmentListener {
override fun onEvent(mediaSegment: MediaSegment, status: SegmentStatus) {
handleMediaSegmentEvent(mediaSegment, status)
}
}
private val listener = object : Player.Listener {
override fun onPositionDiscontinuity(
@@ -99,6 +109,7 @@ class PlayerManager @Inject constructor(
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
clearActiveSkippableSegment()
_currentMediaId.value = mediaItem?.mediaId?.let { UUID.fromString(it) }
scope.launch {
val currentMedia = _playlist.value.firstOrNull { it.id == _currentMediaId.value }
@@ -107,6 +118,7 @@ class PlayerManager @Inject constructor(
return@launch
}
seekTo(currentMedia.resumePositionMs)
installMediaSegments(currentMedia.mediaSegments)
// Only updatePlaylist when episodes are being played. First item is handled when playMedia is being called first time.
if (currentMedia is PlayableMedia.Episode) {
updatePlaylist()
@@ -125,6 +137,7 @@ class PlayerManager @Inject constructor(
}
init {
mediaSegmentManager.registerListener(mediaSegmentListener)
player.addListener(listener)
updateFromPlayer(player)
startProgressLoop()
@@ -192,6 +205,7 @@ class PlayerManager @Inject constructor(
fun seekTo(positionMs: Long) {
val target = clampSeekPosition(positionMs)
clearActiveSegmentIfPositionOutside(target)
pendingSeekPositionMs = target
_progress.update { progress ->
progress.copy(
@@ -219,6 +233,7 @@ class PlayerManager @Inject constructor(
fun next() {
clearPendingSeek()
clearActiveSkippableSegment()
if (player.hasNextMediaItem()) {
player.seekToNextMediaItem()
}
@@ -226,6 +241,7 @@ class PlayerManager @Inject constructor(
fun previous() {
clearPendingSeek()
clearActiveSkippableSegment()
if (player.hasPreviousMediaItem()) {
player.seekToPreviousMediaItem()
}
@@ -282,10 +298,17 @@ class PlayerManager @Inject constructor(
fun retry() {
clearPendingSeek()
clearActiveSkippableSegment()
player.prepare()
player.playWhenReady = true
}
fun skipActiveSegment() {
val mediaSegment = _activeSkippableSegment.value ?: return
seekTo(mediaSegment.endMs)
clearActiveSkippableSegment()
}
fun clearError() {
_playbackState.update { it.copy(error = null) }
}
@@ -342,6 +365,8 @@ class PlayerManager @Inject constructor(
}
fun release() {
clearActiveSkippableSegment()
mediaSegmentManager.release()
scope.cancel()
player.removeListener(listener)
player.release()
@@ -405,6 +430,29 @@ class PlayerManager @Inject constructor(
pendingSeekPositionMs = null
}
private fun clearActiveSkippableSegment() {
_activeSkippableSegment.value = null
}
private fun clearActiveSegmentIfPositionOutside(positionMs: Long) {
val activeSegment = _activeSkippableSegment.value ?: return
if (positionMs < activeSegment.startMs || positionMs >= activeSegment.endMs) {
clearActiveSkippableSegment()
}
}
private fun handleMediaSegmentEvent(mediaSegment: MediaSegment, status: SegmentStatus) {
if (mediaSegment.type == SegmentType.MAIN_CONTENT) return
when (status) {
SegmentStatus.START -> _activeSkippableSegment.value = mediaSegment
SegmentStatus.END -> {
if (_activeSkippableSegment.value?.id == mediaSegment.id) {
clearActiveSkippableSegment()
}
}
}
}
private fun mapPlayerError(error: PlaybackException?): String? {
return error?.errorCodeName ?: error?.localizedMessage ?: error?.message
}

View File

@@ -10,6 +10,7 @@ data class PlayerUiState(
val durationMs: Long = 0L,
val positionMs: Long = 0L,
val bufferedMs: Long = 0L,
val activeSkippableSegmentEndMs: Long? = null,
val error: String? = null,
val playbackSpeed: Float = 1f,
val chapters: List<TimedMarker> = emptyList(),

View File

@@ -121,6 +121,14 @@ class PlayerViewModel @Inject constructor(
}
}
viewModelScope.launch {
playerManager.activeSkippableSegment.collect { mediaSegment ->
_uiState.update {
it.copy(activeSkippableSegmentEndMs = mediaSegment?.endMs)
}
}
}
viewModelScope.launch {
combine(playerManager.playlist, playerManager.currentPlayableMedia) { playlist, currentPlayableMedia ->
playlist.mapNotNull { playableMedia ->
@@ -194,6 +202,10 @@ class PlayerViewModel @Inject constructor(
playerManager.seekToLiveEdge()
}
fun skipActiveSegment() {
playerManager.skipActiveSegment()
}
fun setControlsAutoHideDelay(autoHideDelayMs: Long) {
applyControlsAutoHideCommand(
controlsAutoHidePolicy.setAutoHideDelay(autoHideDelayMs)