diff --git a/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlayTest.kt b/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlayTest.kt index 33b56327..6ddbe43b 100644 --- a/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlayTest.kt +++ b/app-tv/src/androidTest/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlayTest.kt @@ -689,6 +689,7 @@ private fun OverlayHost( onSeek = { _ -> }, onSeekRelative = { _ -> }, onSeekLiveEdge = {}, + onSkipSegment = {}, onNext = {}, onPrevious = {}, onOpenAudioPanel = {}, @@ -804,6 +805,7 @@ private fun TrackPanelHost( onSeek = { _ -> }, onSeekRelative = { _ -> }, onSeekLiveEdge = {}, + onSkipSegment = {}, onNext = {}, onPrevious = {}, onOpenAudioPanel = { diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt index 6995a16a..98c7f9c9 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/TvPlayerScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Pause +import androidx.compose.material.icons.outlined.SkipNext import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -50,6 +51,7 @@ import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import hu.bbara.purefin.player.viewmodel.ControlsAutoHideBlocker 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.TvPlayerLoadingErrorEndCard import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType @@ -116,6 +118,7 @@ fun TvPlayerScreen( val qualityButtonFocusRequester = remember { FocusRequester() } val audioButtonFocusRequester = remember { FocusRequester() } val subtitlesButtonFocusRequester = remember { FocusRequester() } + val skipButtonFocusRequester = remember { FocusRequester() } val expandPlaylist: () -> Unit = { if (!isPlaylistExpanded) { isPlaylistExpanded = true @@ -158,6 +161,10 @@ fun TvPlayerScreen( viewModel.seekToLiveEdge() showTvControls() } + val skipSegmentAndShowControls: () -> Unit = { + viewModel.skipActiveSegment() + showTvControls() + } val nextAndShowControls: () -> Unit = { viewModel.next(TV_CONTROLS_AUTO_HIDE_MS) } @@ -177,6 +184,10 @@ fun TvPlayerScreen( if (controlsVisible) { controlsFocusRequester.requestFocus() } else { + uiState.activeSkippableSegmentEndMs?.let { + skipButtonFocusRequester.requestFocus() + return@LaunchedEffect + } hiddenControlFocusRequester.requestFocus() } } @@ -251,8 +262,10 @@ fun TvPlayerScreen( .align(Alignment.Center) ) + val playerControlsVisible = + controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null AnimatedVisibility( - visible = controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null, + visible = playerControlsVisible, enter = fadeIn(), exit = fadeOut() ) { @@ -268,6 +281,7 @@ fun TvPlayerScreen( onSeek = seekAndShowControls, onSeekRelative = seekByAndShowControls, onSeekLiveEdge = seekToLiveEdgeAndShowControls, + onSkipSegment = skipSegmentAndShowControls, onNext = nextAndShowControls, onPrevious = previousAndShowControls, 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( modifier = Modifier.align(Alignment.Center), uiState = uiState, diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvIconButton.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvIconButton.kt index dee8d8ce..16a5d620 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvIconButton.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvIconButton.kt @@ -5,13 +5,17 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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.semantics.disabled import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @Composable @@ -37,6 +42,7 @@ internal fun TvIconButton( onClick: () -> Unit, size: Int = 52, enabled: Boolean = true, + label: String? = null, modifier: Modifier = Modifier ) { val scheme = MaterialTheme.colorScheme @@ -57,7 +63,7 @@ internal fun TvIconButton( scaleY = scale } .alpha(if (enabled) 1f else 0.4f) - .widthIn(min = size.dp) + .widthIn(min = if (label == null) size.dp else 104.dp) .height(size.dp) .border( width = if (isFocused) 2.dp else 0.dp, @@ -79,11 +85,32 @@ internal fun TvIconButton( .clickable(enabled = enabled) { onClick() }, contentAlignment = Alignment.Center ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = scheme.onBackground, - modifier = Modifier.padding(8.dp) - ) + if (label == null) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + 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 + ) + } + } } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlay.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlay.kt index 35e2caf7..a59a8257 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlay.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerControlsOverlay.kt @@ -65,6 +65,7 @@ internal fun TvPlayerControlsOverlay( onSeek: (Long) -> Unit, onSeekRelative: (Long) -> Unit, onSeekLiveEdge: () -> Unit, + onSkipSegment: () -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, onOpenAudioPanel: () -> Unit, @@ -110,6 +111,7 @@ internal fun TvPlayerControlsOverlay( onSeek = onSeek, onSeekRelative = onSeekRelative, onSeekLiveEdge = onSeekLiveEdge, + onSkipSegment = onSkipSegment, onNext = onNext, onPrevious = onPrevious, onOpenAudioPanel = onOpenAudioPanel, @@ -172,6 +174,7 @@ private fun TvPlayerBottomSection( onSeek: (Long) -> Unit, onSeekRelative: (Long) -> Unit, onSeekLiveEdge: () -> Unit, + onSkipSegment: () -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, onOpenAudioPanel: () -> Unit, @@ -215,6 +218,24 @@ private fun TvPlayerBottomSection( horizontalArrangement = Arrangement.SpaceBetween, 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 = formatTime(uiState.positionMs), color = scheme.onSurface, diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt index a32c05b6..89f456ee 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/player/PlayerScreen.kt @@ -32,12 +32,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView 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.ValueChangeTimedVisibility import hu.bbara.purefin.ui.screen.player.components.PersistentOverlayContainer @@ -168,8 +171,9 @@ fun PlayerScreen( ) } + val playerControlsVisible = controlsVisible || uiState.isEnded || uiState.error != null AnimatedVisibility( - visible = controlsVisible || uiState.isEnded || uiState.error != null, + visible = playerControlsVisible, enter = fadeIn(), exit = fadeOut() ) { @@ -183,6 +187,7 @@ fun PlayerScreen( onSeek = { viewModel.seekTo(it) }, onSeekRelative = { delta -> viewModel.seekBy(delta) }, onSeekLiveEdge = { viewModel.seekToLiveEdge() }, + onSkipSegment = { viewModel.skipActiveSegment() }, onNext = { viewModel.next() }, onPrevious = { viewModel.previous() }, 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( modifier = Modifier.align(Alignment.Center), uiState = uiState, diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerControlsOverlay.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerControlsOverlay.kt index db06e520..6c1f18cb 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerControlsOverlay.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerControlsOverlay.kt @@ -35,10 +35,12 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import hu.bbara.purefin.ui.common.button.GhostIconButton -import hu.bbara.purefin.ui.common.button.PurefinIconButton +import androidx.compose.ui.unit.sp import hu.bbara.purefin.player.model.PlayerUiState 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 fun PlayerControlsOverlay( @@ -50,6 +52,7 @@ fun PlayerControlsOverlay( onSeek: (Long) -> Unit, onSeekRelative: (Long) -> Unit, onSeekLiveEdge: () -> Unit, + onSkipSegment: () -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, onSelectTrack: (TrackOption) -> Unit, @@ -100,6 +103,7 @@ fun PlayerControlsOverlay( onSeekForward = { onSeekRelative(30_000) }, onSeekBackward = { onSeekRelative(-10_000) }, onSeekLiveEdge = onSeekLiveEdge, + onSkipSegment = onSkipSegment, onSelectTrack = onSelectTrack, onQueueSelected = onQueueSelected, modifier = Modifier.align(Alignment.BottomCenter) @@ -163,12 +167,33 @@ private fun BottomSection( onSeekForward: () -> Unit, onSeekBackward: () -> Unit, onSeekLiveEdge: () -> Unit, + onSkipSegment: () -> Unit, onSelectTrack: (TrackOption) -> Unit, onQueueSelected: (String) -> Unit, modifier: Modifier = Modifier ) { val scheme = MaterialTheme.colorScheme 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( modifier = Modifier .fillMaxWidth() diff --git a/core-ui/src/main/java/hu/bbara/purefin/ui/common/button/PurefinTextButton.kt b/core-ui/src/main/java/hu/bbara/purefin/ui/common/button/PurefinTextButton.kt index d8de0106..f8ae6473 100644 --- a/core-ui/src/main/java/hu/bbara/purefin/ui/common/button/PurefinTextButton.kt +++ b/core-ui/src/main/java/hu/bbara/purefin/ui/common/button/PurefinTextButton.kt @@ -19,7 +19,8 @@ fun PurefinTextButton( modifier = modifier, enabled = enabled, colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary ), content = content ) diff --git a/core/src/main/java/hu/bbara/purefin/player/manager/MediaSegmentManager.kt b/core/src/main/java/hu/bbara/purefin/player/manager/MediaSegmentManager.kt index 35b5ca55..237ffcc1 100644 --- a/core/src/main/java/hu/bbara/purefin/player/manager/MediaSegmentManager.kt +++ b/core/src/main/java/hu/bbara/purefin/player/manager/MediaSegmentManager.kt @@ -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 = 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) { - 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() - } -} \ No newline at end of file +} diff --git a/core/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt b/core/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt index 5329b1c5..734850f6 100644 --- a/core/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt +++ b/core/src/main/java/hu/bbara/purefin/player/manager/PlayerManager.kt @@ -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 = _metadata.asStateFlow() private val _tracks = MutableStateFlow(TrackSelectionState()) - val tracks: StateFlow = _tracks.asStateFlow() + private val _activeSkippableSegment = MutableStateFlow(null) + + val activeSkippableSegment: StateFlow = _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 } diff --git a/core/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt b/core/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt index 1650a4c8..d201920d 100644 --- a/core/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt +++ b/core/src/main/java/hu/bbara/purefin/player/model/PlayerUiModels.kt @@ -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 = emptyList(), diff --git a/core/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt b/core/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt index 4807369c..ef8dce2a 100644 --- a/core/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt @@ -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) diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt index d76ab137..b01334ab 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt @@ -184,8 +184,8 @@ class DefaultPlayableMediaRepository @Inject constructor( return MediaSegment( id = itemId, type = segmentType, - startMs = startTicks, - endMs = endTicks + startMs = startTicks / 10_000L, + endMs = endTicks / 10_000L ) } } diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt index 7ac9b0eb..52a4e939 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt @@ -32,7 +32,6 @@ import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.ItemFields 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.MediaType import org.jellyfin.sdk.model.api.PlayMethod @@ -273,7 +272,7 @@ class JellyfinApiClient @Inject constructor( } val result = api.mediaSegmentsApi.getItemSegments( itemId = mediaId, - includeSegmentTypes = listOf(MediaSegmentType.INTRO) + //includeSegmentTypes = listOf(MediaSegmentType.INTRO) ) Log.d("getMediaSegments", result.toString()) result.content.items