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 = { _ -> },
|
onSeek = { _ -> },
|
||||||
onSeekRelative = { _ -> },
|
onSeekRelative = { _ -> },
|
||||||
onSeekLiveEdge = {},
|
onSeekLiveEdge = {},
|
||||||
|
onSkipSegment = {},
|
||||||
onNext = {},
|
onNext = {},
|
||||||
onPrevious = {},
|
onPrevious = {},
|
||||||
onOpenAudioPanel = {},
|
onOpenAudioPanel = {},
|
||||||
@@ -804,6 +805,7 @@ private fun TrackPanelHost(
|
|||||||
onSeek = { _ -> },
|
onSeek = { _ -> },
|
||||||
onSeekRelative = { _ -> },
|
onSeekRelative = { _ -> },
|
||||||
onSeekLiveEdge = {},
|
onSeekLiveEdge = {},
|
||||||
|
onSkipSegment = {},
|
||||||
onNext = {},
|
onNext = {},
|
||||||
onPrevious = {},
|
onPrevious = {},
|
||||||
onOpenAudioPanel = {
|
onOpenAudioPanel = {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Pause
|
import androidx.compose.material.icons.outlined.Pause
|
||||||
|
import androidx.compose.material.icons.outlined.SkipNext
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -50,6 +51,7 @@ import androidx.media3.ui.AspectRatioFrameLayout
|
|||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import hu.bbara.purefin.player.viewmodel.ControlsAutoHideBlocker
|
import hu.bbara.purefin.player.viewmodel.ControlsAutoHideBlocker
|
||||||
import hu.bbara.purefin.player.viewmodel.PlayerViewModel
|
import hu.bbara.purefin.player.viewmodel.PlayerViewModel
|
||||||
|
import hu.bbara.purefin.ui.screen.player.components.TvIconButton
|
||||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerControlsOverlay
|
import hu.bbara.purefin.ui.screen.player.components.TvPlayerControlsOverlay
|
||||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard
|
import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard
|
||||||
import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType
|
import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType
|
||||||
@@ -116,6 +118,7 @@ fun TvPlayerScreen(
|
|||||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||||
val audioButtonFocusRequester = remember { FocusRequester() }
|
val audioButtonFocusRequester = remember { FocusRequester() }
|
||||||
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
val subtitlesButtonFocusRequester = remember { FocusRequester() }
|
||||||
|
val skipButtonFocusRequester = remember { FocusRequester() }
|
||||||
val expandPlaylist: () -> Unit = {
|
val expandPlaylist: () -> Unit = {
|
||||||
if (!isPlaylistExpanded) {
|
if (!isPlaylistExpanded) {
|
||||||
isPlaylistExpanded = true
|
isPlaylistExpanded = true
|
||||||
@@ -158,6 +161,10 @@ fun TvPlayerScreen(
|
|||||||
viewModel.seekToLiveEdge()
|
viewModel.seekToLiveEdge()
|
||||||
showTvControls()
|
showTvControls()
|
||||||
}
|
}
|
||||||
|
val skipSegmentAndShowControls: () -> Unit = {
|
||||||
|
viewModel.skipActiveSegment()
|
||||||
|
showTvControls()
|
||||||
|
}
|
||||||
val nextAndShowControls: () -> Unit = {
|
val nextAndShowControls: () -> Unit = {
|
||||||
viewModel.next(TV_CONTROLS_AUTO_HIDE_MS)
|
viewModel.next(TV_CONTROLS_AUTO_HIDE_MS)
|
||||||
}
|
}
|
||||||
@@ -177,6 +184,10 @@ fun TvPlayerScreen(
|
|||||||
if (controlsVisible) {
|
if (controlsVisible) {
|
||||||
controlsFocusRequester.requestFocus()
|
controlsFocusRequester.requestFocus()
|
||||||
} else {
|
} else {
|
||||||
|
uiState.activeSkippableSegmentEndMs?.let {
|
||||||
|
skipButtonFocusRequester.requestFocus()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
hiddenControlFocusRequester.requestFocus()
|
hiddenControlFocusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,8 +262,10 @@ fun TvPlayerScreen(
|
|||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val playerControlsVisible =
|
||||||
|
controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = controlsVisible || isPlaylistExpanded || trackPanelType != null || uiState.isEnded || uiState.error != null,
|
visible = playerControlsVisible,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut()
|
exit = fadeOut()
|
||||||
) {
|
) {
|
||||||
@@ -268,6 +281,7 @@ fun TvPlayerScreen(
|
|||||||
onSeek = seekAndShowControls,
|
onSeek = seekAndShowControls,
|
||||||
onSeekRelative = seekByAndShowControls,
|
onSeekRelative = seekByAndShowControls,
|
||||||
onSeekLiveEdge = seekToLiveEdgeAndShowControls,
|
onSeekLiveEdge = seekToLiveEdgeAndShowControls,
|
||||||
|
onSkipSegment = skipSegmentAndShowControls,
|
||||||
onNext = nextAndShowControls,
|
onNext = nextAndShowControls,
|
||||||
onPrevious = previousAndShowControls,
|
onPrevious = previousAndShowControls,
|
||||||
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
onOpenAudioPanel = { trackPanelType = TvTrackPanelType.AUDIO },
|
||||||
@@ -285,6 +299,24 @@ fun TvPlayerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(end = 24.dp, bottom = 24.dp)
|
||||||
|
) {
|
||||||
|
TvIconButton(
|
||||||
|
icon = Icons.Outlined.SkipNext,
|
||||||
|
contentDescription = "Skip segment",
|
||||||
|
onClick = skipSegmentAndShowControls,
|
||||||
|
size = 64,
|
||||||
|
label = "Skip",
|
||||||
|
modifier = Modifier.focusRequester(skipButtonFocusRequester)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
TvPlayerLoadingErrorEndCard(
|
TvPlayerLoadingErrorEndCard(
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import androidx.compose.animation.core.animateFloatAsState
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -28,6 +32,7 @@ import androidx.compose.ui.graphics.graphicsLayer
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.semantics.disabled
|
import androidx.compose.ui.semantics.disabled
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -37,6 +42,7 @@ internal fun TvIconButton(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
size: Int = 52,
|
size: Int = 52,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
|
label: String? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
@@ -57,7 +63,7 @@ internal fun TvIconButton(
|
|||||||
scaleY = scale
|
scaleY = scale
|
||||||
}
|
}
|
||||||
.alpha(if (enabled) 1f else 0.4f)
|
.alpha(if (enabled) 1f else 0.4f)
|
||||||
.widthIn(min = size.dp)
|
.widthIn(min = if (label == null) size.dp else 104.dp)
|
||||||
.height(size.dp)
|
.height(size.dp)
|
||||||
.border(
|
.border(
|
||||||
width = if (isFocused) 2.dp else 0.dp,
|
width = if (isFocused) 2.dp else 0.dp,
|
||||||
@@ -79,11 +85,32 @@ internal fun TvIconButton(
|
|||||||
.clickable(enabled = enabled) { onClick() },
|
.clickable(enabled = enabled) { onClick() },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (label == null) {
|
||||||
imageVector = icon,
|
Icon(
|
||||||
contentDescription = contentDescription,
|
imageVector = icon,
|
||||||
tint = scheme.onBackground,
|
contentDescription = contentDescription,
|
||||||
modifier = Modifier.padding(8.dp)
|
tint = scheme.onBackground,
|
||||||
)
|
modifier = Modifier.padding(8.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
tint = scheme.onBackground,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = scheme.onBackground,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ internal fun TvPlayerControlsOverlay(
|
|||||||
onSeek: (Long) -> Unit,
|
onSeek: (Long) -> Unit,
|
||||||
onSeekRelative: (Long) -> Unit,
|
onSeekRelative: (Long) -> Unit,
|
||||||
onSeekLiveEdge: () -> Unit,
|
onSeekLiveEdge: () -> Unit,
|
||||||
|
onSkipSegment: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
onOpenAudioPanel: () -> Unit,
|
onOpenAudioPanel: () -> Unit,
|
||||||
@@ -110,6 +111,7 @@ internal fun TvPlayerControlsOverlay(
|
|||||||
onSeek = onSeek,
|
onSeek = onSeek,
|
||||||
onSeekRelative = onSeekRelative,
|
onSeekRelative = onSeekRelative,
|
||||||
onSeekLiveEdge = onSeekLiveEdge,
|
onSeekLiveEdge = onSeekLiveEdge,
|
||||||
|
onSkipSegment = onSkipSegment,
|
||||||
onNext = onNext,
|
onNext = onNext,
|
||||||
onPrevious = onPrevious,
|
onPrevious = onPrevious,
|
||||||
onOpenAudioPanel = onOpenAudioPanel,
|
onOpenAudioPanel = onOpenAudioPanel,
|
||||||
@@ -172,6 +174,7 @@ private fun TvPlayerBottomSection(
|
|||||||
onSeek: (Long) -> Unit,
|
onSeek: (Long) -> Unit,
|
||||||
onSeekRelative: (Long) -> Unit,
|
onSeekRelative: (Long) -> Unit,
|
||||||
onSeekLiveEdge: () -> Unit,
|
onSeekLiveEdge: () -> Unit,
|
||||||
|
onSkipSegment: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
onOpenAudioPanel: () -> Unit,
|
onOpenAudioPanel: () -> Unit,
|
||||||
@@ -215,6 +218,24 @@ private fun TvPlayerBottomSection(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
if (uiState.activeSkippableSegmentEndMs != null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
TvIconButton(
|
||||||
|
icon = Icons.Outlined.SkipNext,
|
||||||
|
contentDescription = "Skip segment",
|
||||||
|
onClick = onSkipSegment,
|
||||||
|
size = 64,
|
||||||
|
label = "Skip",
|
||||||
|
modifier = expandPlaylistModifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = formatTime(uiState.positionMs),
|
text = formatTime(uiState.positionMs),
|
||||||
color = scheme.onSurface,
|
color = scheme.onSurface,
|
||||||
|
|||||||
@@ -32,12 +32,15 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import hu.bbara.purefin.player.viewmodel.PlayerViewModel
|
import hu.bbara.purefin.player.viewmodel.PlayerViewModel
|
||||||
|
import hu.bbara.purefin.ui.common.button.PurefinTextButton
|
||||||
import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility
|
import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility
|
||||||
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility
|
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility
|
||||||
import hu.bbara.purefin.ui.screen.player.components.PersistentOverlayContainer
|
import hu.bbara.purefin.ui.screen.player.components.PersistentOverlayContainer
|
||||||
@@ -168,8 +171,9 @@ fun PlayerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val playerControlsVisible = controlsVisible || uiState.isEnded || uiState.error != null
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = controlsVisible || uiState.isEnded || uiState.error != null,
|
visible = playerControlsVisible,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut()
|
exit = fadeOut()
|
||||||
) {
|
) {
|
||||||
@@ -183,6 +187,7 @@ fun PlayerScreen(
|
|||||||
onSeek = { viewModel.seekTo(it) },
|
onSeek = { viewModel.seekTo(it) },
|
||||||
onSeekRelative = { delta -> viewModel.seekBy(delta) },
|
onSeekRelative = { delta -> viewModel.seekBy(delta) },
|
||||||
onSeekLiveEdge = { viewModel.seekToLiveEdge() },
|
onSeekLiveEdge = { viewModel.seekToLiveEdge() },
|
||||||
|
onSkipSegment = { viewModel.skipActiveSegment() },
|
||||||
onNext = { viewModel.next() },
|
onNext = { viewModel.next() },
|
||||||
onPrevious = { viewModel.previous() },
|
onPrevious = { viewModel.previous() },
|
||||||
onSelectTrack = { viewModel.selectTrack(it) },
|
onSelectTrack = { viewModel.selectTrack(it) },
|
||||||
@@ -191,6 +196,26 @@ fun PlayerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(end = 24.dp, bottom = 24.dp)
|
||||||
|
) {
|
||||||
|
PurefinTextButton(
|
||||||
|
onClick = { viewModel.skipActiveSegment() },
|
||||||
|
modifier = Modifier.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Skip",
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.ExtraBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PlayerLoadingErrorEndCard(
|
PlayerLoadingErrorEndCard(
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ import androidx.compose.ui.graphics.Brush
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.ui.common.button.GhostIconButton
|
import androidx.compose.ui.unit.sp
|
||||||
import hu.bbara.purefin.ui.common.button.PurefinIconButton
|
|
||||||
import hu.bbara.purefin.player.model.PlayerUiState
|
import hu.bbara.purefin.player.model.PlayerUiState
|
||||||
import hu.bbara.purefin.player.model.TrackOption
|
import hu.bbara.purefin.player.model.TrackOption
|
||||||
|
import hu.bbara.purefin.ui.common.button.GhostIconButton
|
||||||
|
import hu.bbara.purefin.ui.common.button.PurefinIconButton
|
||||||
|
import hu.bbara.purefin.ui.common.button.PurefinTextButton
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayerControlsOverlay(
|
fun PlayerControlsOverlay(
|
||||||
@@ -50,6 +52,7 @@ fun PlayerControlsOverlay(
|
|||||||
onSeek: (Long) -> Unit,
|
onSeek: (Long) -> Unit,
|
||||||
onSeekRelative: (Long) -> Unit,
|
onSeekRelative: (Long) -> Unit,
|
||||||
onSeekLiveEdge: () -> Unit,
|
onSeekLiveEdge: () -> Unit,
|
||||||
|
onSkipSegment: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
onSelectTrack: (TrackOption) -> Unit,
|
onSelectTrack: (TrackOption) -> Unit,
|
||||||
@@ -100,6 +103,7 @@ fun PlayerControlsOverlay(
|
|||||||
onSeekForward = { onSeekRelative(30_000) },
|
onSeekForward = { onSeekRelative(30_000) },
|
||||||
onSeekBackward = { onSeekRelative(-10_000) },
|
onSeekBackward = { onSeekRelative(-10_000) },
|
||||||
onSeekLiveEdge = onSeekLiveEdge,
|
onSeekLiveEdge = onSeekLiveEdge,
|
||||||
|
onSkipSegment = onSkipSegment,
|
||||||
onSelectTrack = onSelectTrack,
|
onSelectTrack = onSelectTrack,
|
||||||
onQueueSelected = onQueueSelected,
|
onQueueSelected = onQueueSelected,
|
||||||
modifier = Modifier.align(Alignment.BottomCenter)
|
modifier = Modifier.align(Alignment.BottomCenter)
|
||||||
@@ -163,12 +167,33 @@ private fun BottomSection(
|
|||||||
onSeekForward: () -> Unit,
|
onSeekForward: () -> Unit,
|
||||||
onSeekBackward: () -> Unit,
|
onSeekBackward: () -> Unit,
|
||||||
onSeekLiveEdge: () -> Unit,
|
onSeekLiveEdge: () -> Unit,
|
||||||
|
onSkipSegment: () -> Unit,
|
||||||
onSelectTrack: (TrackOption) -> Unit,
|
onSelectTrack: (TrackOption) -> Unit,
|
||||||
onQueueSelected: (String) -> Unit,
|
onQueueSelected: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
|
if (uiState.activeSkippableSegmentEndMs != null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
PurefinTextButton(
|
||||||
|
onClick = onSkipSegment,
|
||||||
|
modifier = Modifier.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Skip",
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.ExtraBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ fun PurefinTextButton(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
),
|
),
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,69 +1,83 @@
|
|||||||
package hu.bbara.purefin.player.manager
|
package hu.bbara.purefin.player.manager
|
||||||
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.PlayerMessage
|
|
||||||
import hu.bbara.purefin.model.MediaSegment
|
import hu.bbara.purefin.model.MediaSegment
|
||||||
import hu.bbara.purefin.model.SegmentType
|
|
||||||
import hu.bbara.purefin.player.model.SegmentStatus
|
import hu.bbara.purefin.player.model.SegmentStatus
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
|
||||||
class MediaSegmentManager(private val player: ExoPlayer) {
|
class MediaSegmentManager(private val player: ExoPlayer) {
|
||||||
|
|
||||||
private var listener: MediaSegmentListener? = null
|
private var listener: MediaSegmentListener? = null
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
private var mediaSegments: List<MediaSegment> = emptyList()
|
||||||
|
private var activeSegment: MediaSegment? = null
|
||||||
|
|
||||||
interface MediaSegmentListener {
|
interface MediaSegmentListener {
|
||||||
fun onEvent(segmentType: SegmentType, status: SegmentStatus)
|
fun onEvent(mediaSegment: MediaSegment, status: SegmentStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
startPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun registerListener(listener: MediaSegmentListener) {
|
fun registerListener(listener: MediaSegmentListener) {
|
||||||
this.listener?.let {
|
this.listener?.let {
|
||||||
Log.w("PlayerMessageManager", "Listener was already register")
|
Log.w("MediaSegmentManager", "Listener was already register")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.listener = listener
|
this.listener = listener
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addMediaSegments(mediaSegments: List<MediaSegment>) {
|
fun addMediaSegments(mediaSegments: List<MediaSegment>) {
|
||||||
val messageTarget = PlayerMessage.Target { messageType, payload ->
|
this.mediaSegments = mediaSegments
|
||||||
SegmentType.fromValue(messageType)?.let { segmentType ->
|
activeSegment = null
|
||||||
notifyListener(segmentType, payload as SegmentStatus)
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPolling() {
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
updateActiveSegment()
|
||||||
|
delay(1_000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaSegments.forEach { mediaSegment ->
|
|
||||||
installMediaSegment(messageTarget, mediaSegment)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notifyListener(segmentType: SegmentType, status: SegmentStatus) {
|
private fun updateActiveSegment() {
|
||||||
|
val currentPosition = player.currentPosition
|
||||||
|
val currentSegment = mediaSegments.firstOrNull { mediaSegment ->
|
||||||
|
currentPosition >= mediaSegment.startMs && currentPosition < mediaSegment.endMs
|
||||||
|
}
|
||||||
|
val previousSegment = activeSegment
|
||||||
|
if (previousSegment?.id == currentSegment?.id) return
|
||||||
|
|
||||||
|
previousSegment?.let {
|
||||||
|
notifyListener(it, SegmentStatus.END)
|
||||||
|
}
|
||||||
|
currentSegment?.let {
|
||||||
|
notifyListener(it, SegmentStatus.START)
|
||||||
|
}
|
||||||
|
activeSegment = currentSegment
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyListener(mediaSegment: MediaSegment, status: SegmentStatus) {
|
||||||
|
val listener = listener
|
||||||
if (listener == null) {
|
if (listener == null) {
|
||||||
Log.w("PlayerMessageManager", "Listener was not register therefore it cannot notify")
|
Log.w("MediaSegmentManager", "Listener was not register therefore it cannot notify")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
listener?.onEvent(segmentType, status)
|
Log.d("MediaSegmentManager", "Notify listener about $mediaSegment with status $status")
|
||||||
|
listener.onEvent(mediaSegment, status)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private fun installMediaSegment(messageTarget: PlayerMessage.Target, mediaSegment: MediaSegment) {
|
|
||||||
player.createMessage(messageTarget)
|
|
||||||
.setType(mediaSegment.type.value)
|
|
||||||
.setPosition(mediaSegment.startMs)
|
|
||||||
.setPayload(SegmentStatus.START)
|
|
||||||
.setLooper(Looper.getMainLooper())
|
|
||||||
.setDeleteAfterDelivery(false)
|
|
||||||
.send()
|
|
||||||
|
|
||||||
player.createMessage(messageTarget)
|
|
||||||
.setType(mediaSegment.type.value)
|
|
||||||
.setPayload(SegmentStatus.END)
|
|
||||||
.setPosition(mediaSegment.endMs)
|
|
||||||
.setLooper(Looper.getMainLooper())
|
|
||||||
.setDeleteAfterDelivery(false)
|
|
||||||
.send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import hu.bbara.purefin.data.PlaybackReportContext
|
|||||||
import hu.bbara.purefin.model.AudioTrackProperties
|
import hu.bbara.purefin.model.AudioTrackProperties
|
||||||
import hu.bbara.purefin.model.MediaSegment
|
import hu.bbara.purefin.model.MediaSegment
|
||||||
import hu.bbara.purefin.model.PlayableMedia
|
import hu.bbara.purefin.model.PlayableMedia
|
||||||
|
import hu.bbara.purefin.model.SegmentType
|
||||||
import hu.bbara.purefin.model.SubtitleTrackProperties
|
import hu.bbara.purefin.model.SubtitleTrackProperties
|
||||||
import hu.bbara.purefin.player.model.MetadataState
|
import hu.bbara.purefin.player.model.MetadataState
|
||||||
import hu.bbara.purefin.player.model.PlaybackProgressSnapshot
|
import hu.bbara.purefin.player.model.PlaybackProgressSnapshot
|
||||||
import hu.bbara.purefin.player.model.PlaybackStateSnapshot
|
import hu.bbara.purefin.player.model.PlaybackStateSnapshot
|
||||||
|
import hu.bbara.purefin.player.model.SegmentStatus
|
||||||
import hu.bbara.purefin.player.model.TrackOption
|
import hu.bbara.purefin.player.model.TrackOption
|
||||||
import hu.bbara.purefin.player.model.TrackType
|
import hu.bbara.purefin.player.model.TrackType
|
||||||
import hu.bbara.purefin.player.preference.TrackMatcher
|
import hu.bbara.purefin.player.preference.TrackMatcher
|
||||||
@@ -80,8 +82,16 @@ class PlayerManager @Inject constructor(
|
|||||||
|
|
||||||
val metadata: StateFlow<MetadataState> = _metadata.asStateFlow()
|
val metadata: StateFlow<MetadataState> = _metadata.asStateFlow()
|
||||||
private val _tracks = MutableStateFlow(TrackSelectionState())
|
private val _tracks = MutableStateFlow(TrackSelectionState())
|
||||||
|
|
||||||
val tracks: StateFlow<TrackSelectionState> = _tracks.asStateFlow()
|
val tracks: StateFlow<TrackSelectionState> = _tracks.asStateFlow()
|
||||||
|
private val _activeSkippableSegment = MutableStateFlow<MediaSegment?>(null)
|
||||||
|
|
||||||
|
val activeSkippableSegment: StateFlow<MediaSegment?> = _activeSkippableSegment.asStateFlow()
|
||||||
|
|
||||||
|
private val mediaSegmentListener = object : MediaSegmentManager.MediaSegmentListener {
|
||||||
|
override fun onEvent(mediaSegment: MediaSegment, status: SegmentStatus) {
|
||||||
|
handleMediaSegmentEvent(mediaSegment, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val listener = object : Player.Listener {
|
private val listener = object : Player.Listener {
|
||||||
override fun onPositionDiscontinuity(
|
override fun onPositionDiscontinuity(
|
||||||
@@ -99,6 +109,7 @@ class PlayerManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||||
|
clearActiveSkippableSegment()
|
||||||
_currentMediaId.value = mediaItem?.mediaId?.let { UUID.fromString(it) }
|
_currentMediaId.value = mediaItem?.mediaId?.let { UUID.fromString(it) }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val currentMedia = _playlist.value.firstOrNull { it.id == _currentMediaId.value }
|
val currentMedia = _playlist.value.firstOrNull { it.id == _currentMediaId.value }
|
||||||
@@ -107,6 +118,7 @@ class PlayerManager @Inject constructor(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
seekTo(currentMedia.resumePositionMs)
|
seekTo(currentMedia.resumePositionMs)
|
||||||
|
installMediaSegments(currentMedia.mediaSegments)
|
||||||
// Only updatePlaylist when episodes are being played. First item is handled when playMedia is being called first time.
|
// Only updatePlaylist when episodes are being played. First item is handled when playMedia is being called first time.
|
||||||
if (currentMedia is PlayableMedia.Episode) {
|
if (currentMedia is PlayableMedia.Episode) {
|
||||||
updatePlaylist()
|
updatePlaylist()
|
||||||
@@ -125,6 +137,7 @@ class PlayerManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
mediaSegmentManager.registerListener(mediaSegmentListener)
|
||||||
player.addListener(listener)
|
player.addListener(listener)
|
||||||
updateFromPlayer(player)
|
updateFromPlayer(player)
|
||||||
startProgressLoop()
|
startProgressLoop()
|
||||||
@@ -192,6 +205,7 @@ class PlayerManager @Inject constructor(
|
|||||||
|
|
||||||
fun seekTo(positionMs: Long) {
|
fun seekTo(positionMs: Long) {
|
||||||
val target = clampSeekPosition(positionMs)
|
val target = clampSeekPosition(positionMs)
|
||||||
|
clearActiveSegmentIfPositionOutside(target)
|
||||||
pendingSeekPositionMs = target
|
pendingSeekPositionMs = target
|
||||||
_progress.update { progress ->
|
_progress.update { progress ->
|
||||||
progress.copy(
|
progress.copy(
|
||||||
@@ -219,6 +233,7 @@ class PlayerManager @Inject constructor(
|
|||||||
|
|
||||||
fun next() {
|
fun next() {
|
||||||
clearPendingSeek()
|
clearPendingSeek()
|
||||||
|
clearActiveSkippableSegment()
|
||||||
if (player.hasNextMediaItem()) {
|
if (player.hasNextMediaItem()) {
|
||||||
player.seekToNextMediaItem()
|
player.seekToNextMediaItem()
|
||||||
}
|
}
|
||||||
@@ -226,6 +241,7 @@ class PlayerManager @Inject constructor(
|
|||||||
|
|
||||||
fun previous() {
|
fun previous() {
|
||||||
clearPendingSeek()
|
clearPendingSeek()
|
||||||
|
clearActiveSkippableSegment()
|
||||||
if (player.hasPreviousMediaItem()) {
|
if (player.hasPreviousMediaItem()) {
|
||||||
player.seekToPreviousMediaItem()
|
player.seekToPreviousMediaItem()
|
||||||
}
|
}
|
||||||
@@ -282,10 +298,17 @@ class PlayerManager @Inject constructor(
|
|||||||
|
|
||||||
fun retry() {
|
fun retry() {
|
||||||
clearPendingSeek()
|
clearPendingSeek()
|
||||||
|
clearActiveSkippableSegment()
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.playWhenReady = true
|
player.playWhenReady = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun skipActiveSegment() {
|
||||||
|
val mediaSegment = _activeSkippableSegment.value ?: return
|
||||||
|
seekTo(mediaSegment.endMs)
|
||||||
|
clearActiveSkippableSegment()
|
||||||
|
}
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
_playbackState.update { it.copy(error = null) }
|
_playbackState.update { it.copy(error = null) }
|
||||||
}
|
}
|
||||||
@@ -342,6 +365,8 @@ class PlayerManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
|
clearActiveSkippableSegment()
|
||||||
|
mediaSegmentManager.release()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
player.removeListener(listener)
|
player.removeListener(listener)
|
||||||
player.release()
|
player.release()
|
||||||
@@ -405,6 +430,29 @@ class PlayerManager @Inject constructor(
|
|||||||
pendingSeekPositionMs = null
|
pendingSeekPositionMs = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearActiveSkippableSegment() {
|
||||||
|
_activeSkippableSegment.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearActiveSegmentIfPositionOutside(positionMs: Long) {
|
||||||
|
val activeSegment = _activeSkippableSegment.value ?: return
|
||||||
|
if (positionMs < activeSegment.startMs || positionMs >= activeSegment.endMs) {
|
||||||
|
clearActiveSkippableSegment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMediaSegmentEvent(mediaSegment: MediaSegment, status: SegmentStatus) {
|
||||||
|
if (mediaSegment.type == SegmentType.MAIN_CONTENT) return
|
||||||
|
when (status) {
|
||||||
|
SegmentStatus.START -> _activeSkippableSegment.value = mediaSegment
|
||||||
|
SegmentStatus.END -> {
|
||||||
|
if (_activeSkippableSegment.value?.id == mediaSegment.id) {
|
||||||
|
clearActiveSkippableSegment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun mapPlayerError(error: PlaybackException?): String? {
|
private fun mapPlayerError(error: PlaybackException?): String? {
|
||||||
return error?.errorCodeName ?: error?.localizedMessage ?: error?.message
|
return error?.errorCodeName ?: error?.localizedMessage ?: error?.message
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ data class PlayerUiState(
|
|||||||
val durationMs: Long = 0L,
|
val durationMs: Long = 0L,
|
||||||
val positionMs: Long = 0L,
|
val positionMs: Long = 0L,
|
||||||
val bufferedMs: Long = 0L,
|
val bufferedMs: Long = 0L,
|
||||||
|
val activeSkippableSegmentEndMs: Long? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val playbackSpeed: Float = 1f,
|
val playbackSpeed: Float = 1f,
|
||||||
val chapters: List<TimedMarker> = emptyList(),
|
val chapters: List<TimedMarker> = emptyList(),
|
||||||
|
|||||||
@@ -121,6 +121,14 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
playerManager.activeSkippableSegment.collect { mediaSegment ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(activeSkippableSegmentEndMs = mediaSegment?.endMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
combine(playerManager.playlist, playerManager.currentPlayableMedia) { playlist, currentPlayableMedia ->
|
combine(playerManager.playlist, playerManager.currentPlayableMedia) { playlist, currentPlayableMedia ->
|
||||||
playlist.mapNotNull { playableMedia ->
|
playlist.mapNotNull { playableMedia ->
|
||||||
@@ -194,6 +202,10 @@ class PlayerViewModel @Inject constructor(
|
|||||||
playerManager.seekToLiveEdge()
|
playerManager.seekToLiveEdge()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun skipActiveSegment() {
|
||||||
|
playerManager.skipActiveSegment()
|
||||||
|
}
|
||||||
|
|
||||||
fun setControlsAutoHideDelay(autoHideDelayMs: Long) {
|
fun setControlsAutoHideDelay(autoHideDelayMs: Long) {
|
||||||
applyControlsAutoHideCommand(
|
applyControlsAutoHideCommand(
|
||||||
controlsAutoHidePolicy.setAutoHideDelay(autoHideDelayMs)
|
controlsAutoHidePolicy.setAutoHideDelay(autoHideDelayMs)
|
||||||
|
|||||||
@@ -184,8 +184,8 @@ class DefaultPlayableMediaRepository @Inject constructor(
|
|||||||
return MediaSegment(
|
return MediaSegment(
|
||||||
id = itemId,
|
id = itemId,
|
||||||
type = segmentType,
|
type = segmentType,
|
||||||
startMs = startTicks,
|
startMs = startTicks / 10_000L,
|
||||||
endMs = endTicks
|
endMs = endTicks / 10_000L
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import org.jellyfin.sdk.model.api.CollectionType
|
|||||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
import org.jellyfin.sdk.model.api.ItemFields
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
import org.jellyfin.sdk.model.api.MediaSegmentDto
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
|
||||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
@@ -273,7 +272,7 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
}
|
}
|
||||||
val result = api.mediaSegmentsApi.getItemSegments(
|
val result = api.mediaSegmentsApi.getItemSegments(
|
||||||
itemId = mediaId,
|
itemId = mediaId,
|
||||||
includeSegmentTypes = listOf(MediaSegmentType.INTRO)
|
//includeSegmentTypes = listOf(MediaSegmentType.INTRO)
|
||||||
)
|
)
|
||||||
Log.d("getMediaSegments", result.toString())
|
Log.d("getMediaSegments", result.toString())
|
||||||
result.content.items
|
result.content.items
|
||||||
|
|||||||
Reference in New Issue
Block a user