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