mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Compare commits
3 Commits
9906d473c5
...
f086956578
| Author | SHA1 | Date | |
|---|---|---|---|
| f086956578 | |||
| 01de2ca19c | |||
| 522c8273d5 |
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
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()
|
||||
Log.d("MediaSegmentManager", "Notify listener about $mediaSegment with status $status")
|
||||
listener.onEvent(mediaSegment, status)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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