Compare commits

..

3 Commits

14 changed files with 411 additions and 66 deletions

View File

@@ -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 = {

View File

@@ -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
@@ -48,8 +49,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
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
@@ -57,6 +60,7 @@ import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
import kotlinx.coroutines.delay
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.22f
internal const val TV_HIDDEN_STOP_FEEDBACK_MS = 1_200L
internal const val TvPlayerHiddenStopFeedbackTag = "tv_player_hidden_stop_feedback"
@@ -116,6 +120,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 +163,10 @@ fun TvPlayerScreen(
viewModel.seekToLiveEdge()
showTvControls()
}
val skipSegmentAndShowControls: () -> Unit = {
viewModel.skipActiveSegment()
showTvControls()
}
val nextAndShowControls: () -> Unit = {
viewModel.next(TV_CONTROLS_AUTO_HIDE_MS)
}
@@ -177,6 +186,10 @@ fun TvPlayerScreen(
if (controlsVisible) {
controlsFocusRequester.requestFocus()
} else {
uiState.activeSkippableSegmentEndMs?.let {
skipButtonFocusRequester.requestFocus()
return@LaunchedEffect
}
hiddenControlFocusRequester.requestFocus()
}
}
@@ -204,6 +217,15 @@ fun TvPlayerScreen(
}
}
val playerControlsVisible =
controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null
val subtitleBottomPaddingFraction =
if (playerControlsVisible) {
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
} else {
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
}
BackHandler(enabled = true) {
when {
trackPanelType != null -> closeTrackPanel()
@@ -243,16 +265,20 @@ fun TvPlayerScreen(
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
player = viewModel.player
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
}
},
update = { it.player = viewModel.player },
update = {
it.player = viewModel.player
it.subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
},
modifier = Modifier
.fillMaxHeight()
.align(Alignment.Center)
)
AnimatedVisibility(
visible = controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null,
visible = playerControlsVisible,
enter = fadeIn(),
exit = fadeOut()
) {
@@ -268,6 +294,7 @@ fun TvPlayerScreen(
onSeek = seekAndShowControls,
onSeekRelative = seekByAndShowControls,
onSeekLiveEdge = seekToLiveEdgeAndShowControls,
onSkipSegment = skipSegmentAndShowControls,
onNext = nextAndShowControls,
onPrevious = previousAndShowControls,
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
@@ -285,6 +312,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,

View File

@@ -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
)
}
}
}
}

View File

@@ -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,

View File

@@ -32,12 +32,16 @@ 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 androidx.media3.ui.SubtitleView
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
@@ -50,6 +54,8 @@ import hu.bbara.purefin.ui.screen.player.components.rememberPersistentOverlayCon
import kotlin.math.abs
import kotlin.math.roundToInt
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
@OptIn(UnstableApi::class)
@Composable
fun PlayerScreen(
@@ -75,6 +81,14 @@ fun PlayerScreen(
}
}
val playerControlsVisible = controlsVisible || uiState.isEnded || uiState.error != null
val subtitleBottomPaddingFraction =
if (playerControlsVisible) {
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
} else {
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
}
Box(
modifier = Modifier
.fillMaxSize()
@@ -86,10 +100,12 @@ fun PlayerScreen(
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
player = viewModel.player
subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
}
},
update = {
it.player = viewModel.player
it.subtitleView?.setBottomPaddingFraction(subtitleBottomPaddingFraction)
},
modifier = Modifier
.fillMaxHeight()
@@ -169,7 +185,7 @@ fun PlayerScreen(
}
AnimatedVisibility(
visible = controlsVisible || uiState.isEnded || uiState.error != null,
visible = playerControlsVisible,
enter = fadeIn(),
exit = fadeOut()
) {
@@ -183,6 +199,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 +208,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,

View File

@@ -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()

View File

@@ -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
)

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import hu.bbara.purefin.player.model.TrackOption
import hu.bbara.purefin.player.model.TrackType
import java.util.Locale
import javax.inject.Inject
data class TrackSelectionState(
@@ -35,10 +36,7 @@ class TrackMapper @Inject constructor() {
repeat(group.length) { trackIndex ->
val format = group.getTrackFormat(trackIndex)
val id = "a_${groupIndex}_${trackIndex}"
val label = format.label
?: format.language
?: "${format.channelCount}ch"
?: "Audio ${trackIndex}"
val label = formatAudioLabel(format, trackIndex)
val option = TrackOption(
id = id,
label = label,
@@ -60,10 +58,8 @@ class TrackMapper @Inject constructor() {
repeat(group.length) { trackIndex ->
val format = group.getTrackFormat(trackIndex)
val id = "t_${groupIndex}_${trackIndex}"
val label = format.label
?: format.language
?: "Subtitle ${trackIndex}"
val isForced = (format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0
val label = formatTextLabel(format, trackIndex, isForced)
val option = TrackOption(
id = id,
label = label,
@@ -86,8 +82,7 @@ class TrackMapper @Inject constructor() {
repeat(group.length) { trackIndex ->
val format = group.getTrackFormat(trackIndex)
val id = "v_${groupIndex}_${trackIndex}"
val res = if (format.height != Format.NO_VALUE) "${format.height}p" else null
val label = res ?: format.label ?: "Video ${trackIndex}"
val label = formatVideoLabel(format, trackIndex)
val option = TrackOption(
id = id,
label = label,
@@ -134,4 +129,122 @@ class TrackMapper @Inject constructor() {
selectedVideoTrackId = selectedVideo
)
}
private fun formatAudioLabel(format: Format, trackIndex: Int): String {
val details = listOfNotNull(
displayLanguage(format.language),
audioCodec(format),
channelLabel(format.channelCount)
).joinToString(" ")
return labelWithDetails(
title = format.label,
details = details,
fallback = "Audio ${trackIndex + 1}"
)
}
private fun formatTextLabel(format: Format, trackIndex: Int, isForced: Boolean): String {
val details = listOfNotNull(
displayLanguage(format.language),
subtitleCodec(format),
"Forced".takeIf { isForced }
).joinToString(" ")
return labelWithDetails(
title = format.label,
details = details,
fallback = "Subtitle ${trackIndex + 1}"
)
}
private fun formatVideoLabel(format: Format, trackIndex: Int): String {
val details = if (format.height != Format.NO_VALUE && format.height > 0) {
"${format.height}p"
} else {
null
}
return labelWithDetails(
title = format.label,
details = details,
fallback = "Video ${trackIndex + 1}"
)
}
private fun labelWithDetails(title: String?, details: String?, fallback: String): String {
val cleanTitle = title?.trim()?.takeIf { it.isNotEmpty() }
val cleanDetails = details?.trim()?.takeIf { it.isNotEmpty() }
return when {
cleanTitle != null && cleanDetails != null && cleanTitle != cleanDetails ->
"$cleanTitle - $cleanDetails"
cleanTitle != null -> cleanTitle
cleanDetails != null -> cleanDetails
else -> fallback
}
}
private fun displayLanguage(code: String?): String? {
val cleanCode = code
?.trim()
?.takeIf { it.isNotEmpty() && !it.equals("und", ignoreCase = true) }
?: return null
val locale = Locale.forLanguageTag(cleanCode.replace('_', '-'))
val language = locale.getDisplayLanguage(Locale.getDefault())
return language.takeIf { it.isNotEmpty() && it != cleanCode } ?: cleanCode
}
private fun audioCodec(format: Format): String? {
val value = format.codecText()
return when {
value.contains("truehd") -> "TrueHD"
value.contains("eac3") || value.contains("e-ac-3") || value.contains("ec-3") ->
"Dolby Digital Plus"
value.contains("ac3") || value.contains("ac-3") -> "Dolby Digital"
value.contains("dts") || value.contains("dca") -> "DTS"
value.contains("alac") -> "ALAC"
value.contains("flac") -> "FLAC"
value.contains("opus") -> "Opus"
value.contains("vorbis") || value.contains("ogg") -> "Vorbis"
value.contains("mp3") || value.contains("mpeg") -> "MP3"
value.contains("aac") || value.contains("mp4a") -> "AAC"
value.contains("pcm") || value.contains("raw") || value.contains("lpcm") -> "PCM"
else -> null
}
}
private fun subtitleCodec(format: Format): String? {
val value = format.codecText()
return when {
value.contains("subrip") || value.contains("srt") -> "SRT"
value.contains("ssa") || value.contains("ass") -> "ASS"
value.contains("pgs") || value.contains("pgssub") -> "PGS"
value.contains("dvb") -> "DVB"
value.contains("dvd") || value.contains("vobsub") -> "DVD"
value.contains("vtt") || value.contains("webvtt") -> "VTT"
else -> null
}
}
private fun Format.codecText(): String = listOfNotNull(
sampleMimeType,
containerMimeType,
codecs
).joinToString(" ").lowercase(Locale.US)
private fun channelLabel(channelCount: Int): String? =
when {
channelCount == Format.NO_VALUE || channelCount <= 0 -> null
channelCount == 1 -> "Mono"
channelCount == 2 -> "Stereo"
channelCount == 6 -> "5.1"
channelCount == 8 -> "7.1"
else -> "$channelCount ch"
}
}

View File

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

View File

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

View File

@@ -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
)
}
}

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.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