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

@@ -689,6 +689,7 @@ private fun OverlayHost(
onSeek = { _ -> }, onSeek = { _ -> },
onSeekRelative = { _ -> }, onSeekRelative = { _ -> },
onSeekLiveEdge = {}, onSeekLiveEdge = {},
onSkipSegment = {},
onNext = {}, onNext = {},
onPrevious = {}, onPrevious = {},
onOpenAudioPanel = {}, onOpenAudioPanel = {},
@@ -804,6 +805,7 @@ private fun TrackPanelHost(
onSeek = { _ -> }, onSeek = { _ -> },
onSeekRelative = { _ -> }, onSeekRelative = { _ -> },
onSeekLiveEdge = {}, onSeekLiveEdge = {},
onSkipSegment = {},
onNext = {}, onNext = {},
onPrevious = {}, onPrevious = {},
onOpenAudioPanel = { onOpenAudioPanel = {

View File

@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.SkipNext
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -50,6 +51,7 @@ import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import hu.bbara.purefin.player.viewmodel.ControlsAutoHideBlocker import hu.bbara.purefin.player.viewmodel.ControlsAutoHideBlocker
import hu.bbara.purefin.player.viewmodel.PlayerViewModel import hu.bbara.purefin.player.viewmodel.PlayerViewModel
import hu.bbara.purefin.ui.screen.player.components.TvIconButton
import hu.bbara.purefin.ui.screen.player.components.TvPlayerControlsOverlay import hu.bbara.purefin.ui.screen.player.components.TvPlayerControlsOverlay
import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard
import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType
@@ -116,6 +118,7 @@ fun TvPlayerScreen(
val qualityButtonFocusRequester = remember { FocusRequester() } val qualityButtonFocusRequester = remember { FocusRequester() }
val audioButtonFocusRequester = remember { FocusRequester() } val audioButtonFocusRequester = remember { FocusRequester() }
val subtitlesButtonFocusRequester = remember { FocusRequester() } val subtitlesButtonFocusRequester = remember { FocusRequester() }
val skipButtonFocusRequester = remember { FocusRequester() }
val expandPlaylist: () -> Unit = { val expandPlaylist: () -> Unit = {
if (!isPlaylistExpanded) { if (!isPlaylistExpanded) {
isPlaylistExpanded = true isPlaylistExpanded = true
@@ -158,6 +161,10 @@ fun TvPlayerScreen(
viewModel.seekToLiveEdge() viewModel.seekToLiveEdge()
showTvControls() showTvControls()
} }
val skipSegmentAndShowControls: () -> Unit = {
viewModel.skipActiveSegment()
showTvControls()
}
val nextAndShowControls: () -> Unit = { val nextAndShowControls: () -> Unit = {
viewModel.next(TV_CONTROLS_AUTO_HIDE_MS) viewModel.next(TV_CONTROLS_AUTO_HIDE_MS)
} }
@@ -177,6 +184,10 @@ fun TvPlayerScreen(
if (controlsVisible) { if (controlsVisible) {
controlsFocusRequester.requestFocus() controlsFocusRequester.requestFocus()
} else { } else {
uiState.activeSkippableSegmentEndMs?.let {
skipButtonFocusRequester.requestFocus()
return@LaunchedEffect
}
hiddenControlFocusRequester.requestFocus() hiddenControlFocusRequester.requestFocus()
} }
} }
@@ -251,8 +262,10 @@ fun TvPlayerScreen(
.align(Alignment.Center) .align(Alignment.Center)
) )
val playerControlsVisible =
controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null
AnimatedVisibility( AnimatedVisibility(
visible = controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null, visible = playerControlsVisible,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut()
) { ) {
@@ -268,6 +281,7 @@ fun TvPlayerScreen(
onSeek = seekAndShowControls, onSeek = seekAndShowControls,
onSeekRelative = seekByAndShowControls, onSeekRelative = seekByAndShowControls,
onSeekLiveEdge = seekToLiveEdgeAndShowControls, onSeekLiveEdge = seekToLiveEdgeAndShowControls,
onSkipSegment = skipSegmentAndShowControls,
onNext = nextAndShowControls, onNext = nextAndShowControls,
onPrevious = previousAndShowControls, onPrevious = previousAndShowControls,
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO }, onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
@@ -285,6 +299,24 @@ fun TvPlayerScreen(
) )
} }
AnimatedVisibility(
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 24.dp)
) {
TvIconButton(
icon = Icons.Outlined.SkipNext,
contentDescription = "Skip segment",
onClick = skipSegmentAndShowControls,
size = 64,
label = "Skip",
modifier = Modifier.focusRequester(skipButtonFocusRequester)
)
}
TvPlayerLoadingErrorEndCard( TvPlayerLoadingErrorEndCard(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
uiState = uiState, uiState = uiState,

View File

@@ -5,13 +5,17 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -28,6 +32,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.disabled import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@Composable @Composable
@@ -37,6 +42,7 @@ internal fun TvIconButton(
onClick: () -> Unit, onClick: () -> Unit,
size: Int = 52, size: Int = 52,
enabled: Boolean = true, enabled: Boolean = true,
label: String? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -57,7 +63,7 @@ internal fun TvIconButton(
scaleY = scale scaleY = scale
} }
.alpha(if (enabled) 1f else 0.4f) .alpha(if (enabled) 1f else 0.4f)
.widthIn(min = size.dp) .widthIn(min = if (label == null) size.dp else 104.dp)
.height(size.dp) .height(size.dp)
.border( .border(
width = if (isFocused) 2.dp else 0.dp, width = if (isFocused) 2.dp else 0.dp,
@@ -79,11 +85,32 @@ internal fun TvIconButton(
.clickable(enabled = enabled) { onClick() }, .clickable(enabled = enabled) { onClick() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( if (label == null) {
imageVector = icon, Icon(
contentDescription = contentDescription, imageVector = icon,
tint = scheme.onBackground, contentDescription = contentDescription,
modifier = Modifier.padding(8.dp) tint = scheme.onBackground,
) modifier = Modifier.padding(8.dp)
)
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp)
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = scheme.onBackground,
modifier = Modifier.size(28.dp)
)
Text(
text = label,
color = scheme.onBackground,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
}
} }
} }

View File

@@ -65,6 +65,7 @@ internal fun TvPlayerControlsOverlay(
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
onSeekLiveEdge: () -> Unit, onSeekLiveEdge: () -> Unit,
onSkipSegment: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onPrevious: () -> Unit, onPrevious: () -> Unit,
onOpenAudioPanel: () -> Unit, onOpenAudioPanel: () -> Unit,
@@ -110,6 +111,7 @@ internal fun TvPlayerControlsOverlay(
onSeek = onSeek, onSeek = onSeek,
onSeekRelative = onSeekRelative, onSeekRelative = onSeekRelative,
onSeekLiveEdge = onSeekLiveEdge, onSeekLiveEdge = onSeekLiveEdge,
onSkipSegment = onSkipSegment,
onNext = onNext, onNext = onNext,
onPrevious = onPrevious, onPrevious = onPrevious,
onOpenAudioPanel = onOpenAudioPanel, onOpenAudioPanel = onOpenAudioPanel,
@@ -172,6 +174,7 @@ private fun TvPlayerBottomSection(
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
onSeekLiveEdge: () -> Unit, onSeekLiveEdge: () -> Unit,
onSkipSegment: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onPrevious: () -> Unit, onPrevious: () -> Unit,
onOpenAudioPanel: () -> Unit, onOpenAudioPanel: () -> Unit,
@@ -215,6 +218,24 @@ private fun TvPlayerBottomSection(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (uiState.activeSkippableSegmentEndMs != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.End
) {
TvIconButton(
icon = Icons.Outlined.SkipNext,
contentDescription = "Skip segment",
onClick = onSkipSegment,
size = 64,
label = "Skip",
modifier = expandPlaylistModifier
)
}
Spacer(modifier = Modifier.height(6.dp))
}
Text( Text(
text = formatTime(uiState.positionMs), text = formatTime(uiState.positionMs),
color = scheme.onSurface, color = scheme.onSurface,

View File

@@ -32,12 +32,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import hu.bbara.purefin.player.viewmodel.PlayerViewModel import hu.bbara.purefin.player.viewmodel.PlayerViewModel
import hu.bbara.purefin.ui.common.button.PurefinTextButton
import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility
import hu.bbara.purefin.ui.screen.player.components.PersistentOverlayContainer import hu.bbara.purefin.ui.screen.player.components.PersistentOverlayContainer
@@ -168,8 +171,9 @@ fun PlayerScreen(
) )
} }
val playerControlsVisible = controlsVisible || uiState.isEnded || uiState.error != null
AnimatedVisibility( AnimatedVisibility(
visible = controlsVisible || uiState.isEnded || uiState.error != null, visible = playerControlsVisible,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut()
) { ) {
@@ -183,6 +187,7 @@ fun PlayerScreen(
onSeek = { viewModel.seekTo(it) }, onSeek = { viewModel.seekTo(it) },
onSeekRelative = { delta -> viewModel.seekBy(delta) }, onSeekRelative = { delta -> viewModel.seekBy(delta) },
onSeekLiveEdge = { viewModel.seekToLiveEdge() }, onSeekLiveEdge = { viewModel.seekToLiveEdge() },
onSkipSegment = { viewModel.skipActiveSegment() },
onNext = { viewModel.next() }, onNext = { viewModel.next() },
onPrevious = { viewModel.previous() }, onPrevious = { viewModel.previous() },
onSelectTrack = { viewModel.selectTrack(it) }, onSelectTrack = { viewModel.selectTrack(it) },
@@ -191,6 +196,26 @@ fun PlayerScreen(
) )
} }
AnimatedVisibility(
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 24.dp)
) {
PurefinTextButton(
onClick = { viewModel.skipActiveSegment() },
modifier = Modifier.padding(8.dp)
) {
Text(
text = "Skip",
fontSize = 20.sp,
fontWeight = FontWeight.ExtraBold
)
}
}
PlayerLoadingErrorEndCard( PlayerLoadingErrorEndCard(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
uiState = uiState, uiState = uiState,

View File

@@ -35,10 +35,12 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.button.GhostIconButton import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.common.button.PurefinIconButton
import hu.bbara.purefin.player.model.PlayerUiState import hu.bbara.purefin.player.model.PlayerUiState
import hu.bbara.purefin.player.model.TrackOption import hu.bbara.purefin.player.model.TrackOption
import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.PurefinIconButton
import hu.bbara.purefin.ui.common.button.PurefinTextButton
@Composable @Composable
fun PlayerControlsOverlay( fun PlayerControlsOverlay(
@@ -50,6 +52,7 @@ fun PlayerControlsOverlay(
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
onSeekRelative: (Long) -> Unit, onSeekRelative: (Long) -> Unit,
onSeekLiveEdge: () -> Unit, onSeekLiveEdge: () -> Unit,
onSkipSegment: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onPrevious: () -> Unit, onPrevious: () -> Unit,
onSelectTrack: (TrackOption) -> Unit, onSelectTrack: (TrackOption) -> Unit,
@@ -100,6 +103,7 @@ fun PlayerControlsOverlay(
onSeekForward = { onSeekRelative(30_000) }, onSeekForward = { onSeekRelative(30_000) },
onSeekBackward = { onSeekRelative(-10_000) }, onSeekBackward = { onSeekRelative(-10_000) },
onSeekLiveEdge = onSeekLiveEdge, onSeekLiveEdge = onSeekLiveEdge,
onSkipSegment = onSkipSegment,
onSelectTrack = onSelectTrack, onSelectTrack = onSelectTrack,
onQueueSelected = onQueueSelected, onQueueSelected = onQueueSelected,
modifier = Modifier.align(Alignment.BottomCenter) modifier = Modifier.align(Alignment.BottomCenter)
@@ -163,12 +167,33 @@ private fun BottomSection(
onSeekForward: () -> Unit, onSeekForward: () -> Unit,
onSeekBackward: () -> Unit, onSeekBackward: () -> Unit,
onSeekLiveEdge: () -> Unit, onSeekLiveEdge: () -> Unit,
onSkipSegment: () -> Unit,
onSelectTrack: (TrackOption) -> Unit, onSelectTrack: (TrackOption) -> Unit,
onQueueSelected: (String) -> Unit, onQueueSelected: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
Column(modifier = modifier) { Column(modifier = modifier) {
if (uiState.activeSkippableSegmentEndMs != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.End
) {
PurefinTextButton(
onClick = onSkipSegment,
modifier = Modifier.padding(8.dp)
) {
Text(
text = "Skip",
fontSize = 20.sp,
fontWeight = FontWeight.ExtraBold
)
}
}
Spacer(modifier = Modifier.height(6.dp))
}
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

@@ -19,7 +19,8 @@ fun PurefinTextButton(
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
), ),
content = content content = content
) )

View File

@@ -1,69 +1,83 @@
package hu.bbara.purefin.player.manager package hu.bbara.purefin.player.manager
import android.os.Looper
import android.util.Log import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.PlayerMessage
import hu.bbara.purefin.model.MediaSegment import hu.bbara.purefin.model.MediaSegment
import hu.bbara.purefin.model.SegmentType
import hu.bbara.purefin.player.model.SegmentStatus 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) { class MediaSegmentManager(private val player: ExoPlayer) {
private var listener: MediaSegmentListener? = null 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 { interface MediaSegmentListener {
fun onEvent(segmentType: SegmentType, status: SegmentStatus) fun onEvent(mediaSegment: MediaSegment, status: SegmentStatus)
}
init {
startPolling()
} }
@Synchronized @Synchronized
fun registerListener(listener: MediaSegmentListener) { fun registerListener(listener: MediaSegmentListener) {
this.listener?.let { this.listener?.let {
Log.w("PlayerMessageManager", "Listener was already register") Log.w("MediaSegmentManager", "Listener was already register")
return return
} }
this.listener = listener this.listener = listener
} }
fun addMediaSegments(mediaSegments: List<MediaSegment>) { fun addMediaSegments(mediaSegments: List<MediaSegment>) {
val messageTarget = PlayerMessage.Target { messageType, payload -> this.mediaSegments = mediaSegments
SegmentType.fromValue(messageType)?.let { segmentType -> activeSegment = null
notifyListener(segmentType, payload as SegmentStatus) }
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) { 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.AudioTrackProperties
import hu.bbara.purefin.model.MediaSegment import hu.bbara.purefin.model.MediaSegment
import hu.bbara.purefin.model.PlayableMedia import hu.bbara.purefin.model.PlayableMedia
import hu.bbara.purefin.model.SegmentType
import hu.bbara.purefin.model.SubtitleTrackProperties import hu.bbara.purefin.model.SubtitleTrackProperties
import hu.bbara.purefin.player.model.MetadataState import hu.bbara.purefin.player.model.MetadataState
import hu.bbara.purefin.player.model.PlaybackProgressSnapshot import hu.bbara.purefin.player.model.PlaybackProgressSnapshot
import hu.bbara.purefin.player.model.PlaybackStateSnapshot 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.TrackOption
import hu.bbara.purefin.player.model.TrackType import hu.bbara.purefin.player.model.TrackType
import hu.bbara.purefin.player.preference.TrackMatcher import hu.bbara.purefin.player.preference.TrackMatcher
@@ -80,8 +82,16 @@ class PlayerManager @Inject constructor(
val metadata: StateFlow<MetadataState> = _metadata.asStateFlow() val metadata: StateFlow<MetadataState> = _metadata.asStateFlow()
private val _tracks = MutableStateFlow(TrackSelectionState()) private val _tracks = MutableStateFlow(TrackSelectionState())
val tracks: StateFlow<TrackSelectionState> = _tracks.asStateFlow() 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 { private val listener = object : Player.Listener {
override fun onPositionDiscontinuity( override fun onPositionDiscontinuity(
@@ -99,6 +109,7 @@ class PlayerManager @Inject constructor(
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
clearActiveSkippableSegment()
_currentMediaId.value = mediaItem?.mediaId?.let { UUID.fromString(it) } _currentMediaId.value = mediaItem?.mediaId?.let { UUID.fromString(it) }
scope.launch { scope.launch {
val currentMedia = _playlist.value.firstOrNull { it.id == _currentMediaId.value } val currentMedia = _playlist.value.firstOrNull { it.id == _currentMediaId.value }
@@ -107,6 +118,7 @@ class PlayerManager @Inject constructor(
return@launch return@launch
} }
seekTo(currentMedia.resumePositionMs) seekTo(currentMedia.resumePositionMs)
installMediaSegments(currentMedia.mediaSegments)
// Only updatePlaylist when episodes are being played. First item is handled when playMedia is being called first time. // Only updatePlaylist when episodes are being played. First item is handled when playMedia is being called first time.
if (currentMedia is PlayableMedia.Episode) { if (currentMedia is PlayableMedia.Episode) {
updatePlaylist() updatePlaylist()
@@ -125,6 +137,7 @@ class PlayerManager @Inject constructor(
} }
init { init {
mediaSegmentManager.registerListener(mediaSegmentListener)
player.addListener(listener) player.addListener(listener)
updateFromPlayer(player) updateFromPlayer(player)
startProgressLoop() startProgressLoop()
@@ -192,6 +205,7 @@ class PlayerManager @Inject constructor(
fun seekTo(positionMs: Long) { fun seekTo(positionMs: Long) {
val target = clampSeekPosition(positionMs) val target = clampSeekPosition(positionMs)
clearActiveSegmentIfPositionOutside(target)
pendingSeekPositionMs = target pendingSeekPositionMs = target
_progress.update { progress -> _progress.update { progress ->
progress.copy( progress.copy(
@@ -219,6 +233,7 @@ class PlayerManager @Inject constructor(
fun next() { fun next() {
clearPendingSeek() clearPendingSeek()
clearActiveSkippableSegment()
if (player.hasNextMediaItem()) { if (player.hasNextMediaItem()) {
player.seekToNextMediaItem() player.seekToNextMediaItem()
} }
@@ -226,6 +241,7 @@ class PlayerManager @Inject constructor(
fun previous() { fun previous() {
clearPendingSeek() clearPendingSeek()
clearActiveSkippableSegment()
if (player.hasPreviousMediaItem()) { if (player.hasPreviousMediaItem()) {
player.seekToPreviousMediaItem() player.seekToPreviousMediaItem()
} }
@@ -282,10 +298,17 @@ class PlayerManager @Inject constructor(
fun retry() { fun retry() {
clearPendingSeek() clearPendingSeek()
clearActiveSkippableSegment()
player.prepare() player.prepare()
player.playWhenReady = true player.playWhenReady = true
} }
fun skipActiveSegment() {
val mediaSegment = _activeSkippableSegment.value ?: return
seekTo(mediaSegment.endMs)
clearActiveSkippableSegment()
}
fun clearError() { fun clearError() {
_playbackState.update { it.copy(error = null) } _playbackState.update { it.copy(error = null) }
} }
@@ -342,6 +365,8 @@ class PlayerManager @Inject constructor(
} }
fun release() { fun release() {
clearActiveSkippableSegment()
mediaSegmentManager.release()
scope.cancel() scope.cancel()
player.removeListener(listener) player.removeListener(listener)
player.release() player.release()
@@ -405,6 +430,29 @@ class PlayerManager @Inject constructor(
pendingSeekPositionMs = null 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? { private fun mapPlayerError(error: PlaybackException?): String? {
return error?.errorCodeName ?: error?.localizedMessage ?: error?.message return error?.errorCodeName ?: error?.localizedMessage ?: error?.message
} }

View File

@@ -10,6 +10,7 @@ data class PlayerUiState(
val durationMs: Long = 0L, val durationMs: Long = 0L,
val positionMs: Long = 0L, val positionMs: Long = 0L,
val bufferedMs: Long = 0L, val bufferedMs: Long = 0L,
val activeSkippableSegmentEndMs: Long? = null,
val error: String? = null, val error: String? = null,
val playbackSpeed: Float = 1f, val playbackSpeed: Float = 1f,
val chapters: List<TimedMarker> = emptyList(), 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 { viewModelScope.launch {
combine(playerManager.playlist, playerManager.currentPlayableMedia) { playlist, currentPlayableMedia -> combine(playerManager.playlist, playerManager.currentPlayableMedia) { playlist, currentPlayableMedia ->
playlist.mapNotNull { playableMedia -> playlist.mapNotNull { playableMedia ->
@@ -194,6 +202,10 @@ class PlayerViewModel @Inject constructor(
playerManager.seekToLiveEdge() playerManager.seekToLiveEdge()
} }
fun skipActiveSegment() {
playerManager.skipActiveSegment()
}
fun setControlsAutoHideDelay(autoHideDelayMs: Long) { fun setControlsAutoHideDelay(autoHideDelayMs: Long) {
applyControlsAutoHideCommand( applyControlsAutoHideCommand(
controlsAutoHidePolicy.setAutoHideDelay(autoHideDelayMs) controlsAutoHidePolicy.setAutoHideDelay(autoHideDelayMs)

View File

@@ -184,8 +184,8 @@ class DefaultPlayableMediaRepository @Inject constructor(
return MediaSegment( return MediaSegment(
id = itemId, id = itemId,
type = segmentType, type = segmentType,
startMs = startTicks, startMs = startTicks / 10_000L,
endMs = endTicks endMs = endTicks / 10_000L
) )
} }
} }

View File

@@ -32,7 +32,6 @@ import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlayMethod
@@ -273,7 +272,7 @@ class JellyfinApiClient @Inject constructor(
} }
val result = api.mediaSegmentsApi.getItemSegments( val result = api.mediaSegmentsApi.getItemSegments(
itemId = mediaId, itemId = mediaId,
includeSegmentTypes = listOf(MediaSegmentType.INTRO) //includeSegmentTypes = listOf(MediaSegmentType.INTRO)
) )
Log.d("getMediaSegments", result.toString()) Log.d("getMediaSegments", result.toString())
result.content.items result.content.items