mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
feat: add skip segment functionality to player
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user