mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Compare commits
5 Commits
84a95b0af6
...
3c16817442
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c16817442 | |||
| c4eed9ece3 | |||
| f444a5e09f | |||
| 522b93ff06 | |||
| 1fc291d2d8 |
@@ -17,7 +17,7 @@ import hu.bbara.purefin.model.Season
|
|||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
|
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
|
||||||
import hu.bbara.purefin.ui.screen.series.components.SeriesPlayButtonTag
|
import hu.bbara.purefin.ui.screen.series.components.SeriesNextUpEpisodeCardTag
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -30,7 +30,7 @@ class SeriesScreenContentTest {
|
|||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() {
|
fun seriesScreenContent_focusesNextUpEpisode_whenNextUpExists() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
TvSeriesScreenContent(
|
TvSeriesScreenContent(
|
||||||
@@ -44,8 +44,7 @@ class SeriesScreenContentTest {
|
|||||||
|
|
||||||
composeRule.onNodeWithText("Severance").assertIsDisplayed()
|
composeRule.onNodeWithText("Severance").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
|
composeRule.onNodeWithTag(SeriesNextUpEpisodeCardTag).assertIsDisplayed()
|
||||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed()
|
|
||||||
.assertIsFocused()
|
.assertIsFocused()
|
||||||
composeRule.onNodeWithText("Season 1").assertIsDisplayed()
|
composeRule.onNodeWithText("Season 1").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
|
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
|
||||||
@@ -66,7 +65,6 @@ class SeriesScreenContentTest {
|
|||||||
composeRule.waitForIdle()
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag)
|
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag)
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
.assertIsFocused()
|
.assertIsFocused()
|
||||||
@@ -87,7 +85,7 @@ class SeriesScreenContentTest {
|
|||||||
|
|
||||||
composeRule.waitForIdle()
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
|
composeRule.onNodeWithTag(SeriesNextUpEpisodeCardTag).assertIsFocused()
|
||||||
composeRule.onRoot().performKeyInput {
|
composeRule.onRoot().performKeyInput {
|
||||||
pressKey(Key.DirectionCenter)
|
pressKey(Key.DirectionCenter)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
@@ -31,13 +27,13 @@ import androidx.compose.ui.platform.LocalConfiguration
|
|||||||
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 androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
|
||||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
|
||||||
import hu.bbara.purefin.image.ArtworkKind
|
import hu.bbara.purefin.image.ArtworkKind
|
||||||
|
import hu.bbara.purefin.image.ImageUrlBuilder
|
||||||
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
|
|
||||||
internal val MediaDetailHorizontalPadding = 48.dp
|
internal val MediaDetailHorizontalPadding = 48.dp
|
||||||
private val MediaDetailHeaderTopPadding = 104.dp
|
private val MediaDetailHeaderTopPadding = 6.dp
|
||||||
private val MediaDetailHeaderBottomPadding = 36.dp
|
private val MediaDetailHeaderBottomPadding = 4.dp
|
||||||
private const val MediaDetailBodyImageWidthFraction = 0.66f
|
private const val MediaDetailBodyImageWidthFraction = 0.66f
|
||||||
|
|
||||||
internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
|
internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
|
||||||
@@ -61,19 +57,13 @@ internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
|
|||||||
internal fun TvMediaDetailScaffold(
|
internal fun TvMediaDetailScaffold(
|
||||||
resetScrollKey: Any,
|
resetScrollKey: Any,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> }
|
bodyContent: @Composable (Modifier) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val contentPadding = Modifier.padding(horizontal = MediaDetailHorizontalPadding)
|
val contentPadding = Modifier.padding(horizontal = MediaDetailHorizontalPadding)
|
||||||
val listState = rememberLazyListState()
|
|
||||||
|
|
||||||
LaunchedEffect(resetScrollKey) {
|
|
||||||
listState.scrollToItem(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
CompositionLocalProvider(LocalBringIntoViewSpec provides TvMediaDetailBringIntoViewSpec) {
|
CompositionLocalProvider(LocalBringIntoViewSpec provides TvMediaDetailBringIntoViewSpec) {
|
||||||
LazyColumn(
|
Column(
|
||||||
state = listState,
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(scheme.background)
|
.background(scheme.background)
|
||||||
@@ -87,7 +77,7 @@ internal fun TvMediaDetailScaffold(
|
|||||||
internal fun TvMediaDetailBodyBox(
|
internal fun TvMediaDetailBodyBox(
|
||||||
backgroundImageUrl: String,
|
backgroundImageUrl: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
heightFraction: Float = 0.48f,
|
heightFraction: Float = 0.24f,
|
||||||
content: @Composable ColumnScope.() -> Unit
|
content: @Composable ColumnScope.() -> Unit
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
@@ -183,12 +173,11 @@ internal fun MediaDetailOverviewSection(
|
|||||||
title = "Overview",
|
title = "Overview",
|
||||||
titleColor = scheme.onBackground,
|
titleColor = scheme.onBackground,
|
||||||
bodyColor = scheme.onSurfaceVariant.copy(alpha = 0.85f),
|
bodyColor = scheme.onSurfaceVariant.copy(alpha = 0.85f),
|
||||||
titleFontSize = 22.sp,
|
titleFontSize = 15.sp,
|
||||||
bodyFontSize = 16.sp,
|
bodyFontSize = 11.sp,
|
||||||
bodyLineHeight = 24.sp,
|
bodyLineHeight = 14.sp,
|
||||||
titleSpacing = 14.dp,
|
titleSpacing = 2.dp,
|
||||||
collapsedLines = 5,
|
collapsedLines = 1
|
||||||
collapseInitially = false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,50 +85,43 @@ internal fun TvEpisodeScreenContent(
|
|||||||
resetScrollKey = episode.id,
|
resetScrollKey = episode.id,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
item(key = "episode-hero") {
|
TvMediaDetailBodyBox(
|
||||||
TvMediaDetailBodyBox(
|
backgroundImageUrl = ImageUrlBuilder.finishImageUrl(
|
||||||
backgroundImageUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
|
episode.imageUrlPrefix,
|
||||||
modifier = it
|
ArtworkKind.PRIMARY
|
||||||
) {
|
),
|
||||||
TvEpisodeHeroSection(
|
modifier = it
|
||||||
episode = episode,
|
) {
|
||||||
seriesTitle = seriesTitle,
|
TvEpisodeHeroSection(
|
||||||
onPlay = onPlay,
|
episode = episode,
|
||||||
playFocusRequester = playFocusRequester,
|
seriesTitle = seriesTitle,
|
||||||
modifier = Modifier.fillMaxWidth()
|
onPlay = onPlay,
|
||||||
)
|
playFocusRequester = playFocusRequester,
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
modifier = Modifier.fillMaxWidth()
|
||||||
}
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
}
|
}
|
||||||
item(key = "episode-overview") {
|
Column(modifier = it.fillMaxWidth()) {
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
MediaDetailOverviewSection(
|
||||||
MediaDetailOverviewSection(
|
synopsis = episode.synopsis,
|
||||||
synopsis = episode.synopsis,
|
modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth()
|
)
|
||||||
)
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
item(key = "episode-playback") {
|
Column(modifier = it.fillMaxWidth()) {
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
MediaDetailPlaybackSection(
|
||||||
MediaDetailPlaybackSection(
|
audioTrack = "ENG",
|
||||||
audioTrack = "ENG",
|
subtitles = "ENG",
|
||||||
subtitles = "ENG",
|
modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth()
|
)
|
||||||
)
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (episode.cast.isNotEmpty()) {
|
Column(modifier = it.fillMaxWidth()) {
|
||||||
item(key = "episode-cast") {
|
MediaDetailSectionTitle(text = "Cast")
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
MediaDetailSectionTitle(text = "Cast")
|
// MediaCastRow(cast = episode.cast)
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
// MediaCastRow(cast = episode.cast)
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,47 +39,47 @@ internal fun TvEpisodeHeroSection(
|
|||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.widthIn(max = 760.dp)
|
.widthIn(max = 560.dp)
|
||||||
) {
|
) {
|
||||||
if (!seriesTitle.isNullOrBlank()) {
|
if (!seriesTitle.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = seriesTitle,
|
text = seriesTitle,
|
||||||
color = scheme.primary,
|
color = scheme.primary,
|
||||||
fontSize = 18.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = episode.title,
|
text = episode.title,
|
||||||
color = scheme.onBackground,
|
color = scheme.onBackground,
|
||||||
fontSize = 42.sp,
|
fontSize = 24.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
lineHeight = 48.sp,
|
lineHeight = 28.sp,
|
||||||
maxLines = 2,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Episode ${episode.index}",
|
text = "Episode ${episode.index}",
|
||||||
color = mutedStrong,
|
color = mutedStrong,
|
||||||
fontSize = 18.sp,
|
fontSize = 10.sp,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(18.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
MediaMetadataFlowRow(
|
MediaMetadataFlowRow(
|
||||||
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
|
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
|
||||||
highlightedItem = episode.format
|
highlightedItem = episode.format
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
MediaResumeButton(
|
MediaResumeButton(
|
||||||
text = mediaPlayButtonText(episode.progress, episode.watched),
|
text = mediaPlayButtonText(episode.progress, episode.watched),
|
||||||
progress = mediaPlaybackProgress(episode.progress),
|
progress = mediaPlaybackProgress(episode.progress),
|
||||||
onClick = onPlay,
|
onClick = onPlay,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
.sizeIn(minWidth = 160.dp, maxWidth = 192.dp)
|
||||||
.focusRequester(playFocusRequester)
|
.focusRequester(playFocusRequester)
|
||||||
.testTag(EpisodePlayButtonTag),
|
.testTag(EpisodePlayButtonTag),
|
||||||
focusedScale = 1.08f,
|
focusedScale = 1.08f,
|
||||||
@@ -88,7 +88,10 @@ internal fun TvEpisodeHeroSection(
|
|||||||
focusBorderColor = scheme.onBackground,
|
focusBorderColor = scheme.onBackground,
|
||||||
overlayBorderWidth = 2.dp,
|
overlayBorderWidth = 2.dp,
|
||||||
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
|
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
|
||||||
focusable = true
|
focusable = true,
|
||||||
|
height = 40.dp,
|
||||||
|
textSize = 13.sp,
|
||||||
|
iconSize = 20.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package hu.bbara.purefin.ui.screen.movie
|
package hu.bbara.purefin.ui.screen.movie
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -11,14 +11,11 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
|
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.navigation.MovieDto
|
import hu.bbara.purefin.navigation.MovieDto
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
|
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection
|
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
|
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
||||||
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
||||||
@@ -27,7 +24,9 @@ import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvMovieScreen(
|
fun TvMovieScreen(
|
||||||
movie: MovieDto, viewModel: MovieScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier
|
movie: MovieDto,
|
||||||
|
viewModel: MovieScreenViewModel = hiltViewModel(),
|
||||||
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(movie.id) {
|
LaunchedEffect(movie.id) {
|
||||||
viewModel.selectMovie(movie.id)
|
viewModel.selectMovie(movie.id)
|
||||||
@@ -63,10 +62,15 @@ internal fun TvMovieScreenContent(
|
|||||||
resetScrollKey = movie.id,
|
resetScrollKey = movie.id,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
item(key = "movie-hero") {
|
TvMediaDetailBodyBox(
|
||||||
TvMediaDetailBodyBox(
|
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
|
||||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = it
|
heightFraction = 1f
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = MediaDetailHorizontalPadding)
|
||||||
) {
|
) {
|
||||||
TvMovieHeroSection(
|
TvMovieHeroSection(
|
||||||
movie = movie,
|
movie = movie,
|
||||||
@@ -74,37 +78,6 @@ internal fun TvMovieScreenContent(
|
|||||||
playFocusRequester = playFocusRequester,
|
playFocusRequester = playFocusRequester,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item(key = "movie-overview") {
|
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
MediaDetailOverviewSection(
|
|
||||||
synopsis = movie.synopsis,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item(key = "movie-playback") {
|
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
|
||||||
MediaDetailPlaybackSection(
|
|
||||||
audioTrack = "ENG",
|
|
||||||
subtitles = "ENG",
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (movie.cast.isNotEmpty()) {
|
|
||||||
item(key = "movie-cast") {
|
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
|
||||||
MediaDetailSectionTitle(text = "Cast")
|
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
|
||||||
// MediaCastRow(cast = movie.cast)
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.sizeIn
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -17,11 +17,11 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
||||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
|
||||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
||||||
|
|
||||||
internal const val MoviePlayButtonTag = "movie-play-button"
|
internal const val MoviePlayButtonTag = "movie-play-button"
|
||||||
|
|
||||||
@@ -33,33 +33,67 @@ internal fun TvMovieHeroSection(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.85f)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.padding(top = 24.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.widthIn(max = 760.dp)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = movie.title,
|
text = movie.title,
|
||||||
color = scheme.onBackground,
|
color = scheme.onBackground,
|
||||||
fontSize = 42.sp,
|
fontSize = 24.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
lineHeight = 48.sp,
|
lineHeight = 28.sp,
|
||||||
maxLines = 2,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(18.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
MediaMetadataFlowRow(
|
MediaMetadataFlowRow(
|
||||||
items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
|
items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
|
||||||
highlightedItem = movie.format
|
highlightedItem = movie.format
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = "Overview",
|
||||||
|
color = scheme.onBackground,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = movie.synopsis,
|
||||||
|
color = mutedStrong,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth(0.4f)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = "Playback",
|
||||||
|
color = scheme.onBackground,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = "ENG • Subtitles ENG",
|
||||||
|
color = mutedStrong,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
MediaResumeButton(
|
MediaResumeButton(
|
||||||
text = mediaPlayButtonText(movie.progress, movie.watched),
|
text = mediaPlayButtonText(movie.progress, movie.watched),
|
||||||
progress = mediaPlaybackProgress(movie.progress),
|
progress = mediaPlaybackProgress(movie.progress),
|
||||||
onClick = onPlay,
|
onClick = onPlay,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
.sizeIn(minWidth = 160.dp, maxWidth = 192.dp)
|
||||||
.focusRequester(playFocusRequester)
|
.focusRequester(playFocusRequester)
|
||||||
.testTag(MoviePlayButtonTag),
|
.testTag(MoviePlayButtonTag),
|
||||||
focusedScale = 1.08f,
|
focusedScale = 1.08f,
|
||||||
@@ -68,7 +102,10 @@ internal fun TvMovieHeroSection(
|
|||||||
focusBorderColor = scheme.onBackground,
|
focusBorderColor = scheme.onBackground,
|
||||||
overlayBorderWidth = 2.dp,
|
overlayBorderWidth = 2.dp,
|
||||||
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
|
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
|
||||||
focusable = true
|
focusable = true,
|
||||||
|
height = 40.dp,
|
||||||
|
textSize = 13.sp,
|
||||||
|
iconSize = 20.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package hu.bbara.purefin.ui.screen.series
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -17,15 +19,14 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import hu.bbara.purefin.feature.content.series.SeriesViewModel
|
import hu.bbara.purefin.feature.content.series.SeriesViewModel
|
||||||
|
import hu.bbara.purefin.model.Episode
|
||||||
import hu.bbara.purefin.model.Season
|
import hu.bbara.purefin.model.Season
|
||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
import hu.bbara.purefin.navigation.SeriesDto
|
import hu.bbara.purefin.navigation.SeriesDto
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
|
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
|
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
||||||
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
||||||
import hu.bbara.purefin.ui.screen.series.components.CastRow
|
|
||||||
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
|
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
|
||||||
import hu.bbara.purefin.ui.screen.series.components.TvEpisodeCarousel
|
import hu.bbara.purefin.ui.screen.series.components.TvEpisodeCarousel
|
||||||
import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs
|
import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs
|
||||||
@@ -63,82 +64,62 @@ internal fun TvSeriesScreenContent(
|
|||||||
onPlayEpisode: (UUID) -> Unit,
|
onPlayEpisode: (UUID) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var selectedSeason by remember(series.id) { mutableStateOf(series.defaultSeason()) }
|
|
||||||
val nextUpEpisode = remember(series.id) { series.nextUpEpisode() }
|
val nextUpEpisode = remember(series.id) { series.nextUpEpisode() }
|
||||||
val playFocusRequester = remember { FocusRequester() }
|
var selectedSeason by remember(series.id, nextUpEpisode?.seasonId) {
|
||||||
|
mutableStateOf(series.defaultSeason(nextUpEpisode))
|
||||||
|
}
|
||||||
val firstContentFocusRequester = remember { FocusRequester() }
|
val firstContentFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(series.id, nextUpEpisode?.id) {
|
LaunchedEffect(series.id, nextUpEpisode?.id) {
|
||||||
|
if (nextUpEpisode != null) return@LaunchedEffect
|
||||||
withFrameNanos { }
|
withFrameNanos { }
|
||||||
if (nextUpEpisode != null) {
|
firstContentFocusRequester.requestFocus()
|
||||||
playFocusRequester.requestFocus()
|
|
||||||
} else {
|
|
||||||
firstContentFocusRequester.requestFocus()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TvMediaDetailScaffold(
|
TvMediaDetailScaffold(
|
||||||
resetScrollKey = series.id,
|
resetScrollKey = series.id,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
item(key = "series-hero") {
|
TvMediaDetailBodyBox(
|
||||||
TvMediaDetailBodyBox(
|
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
|
||||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = it
|
heightFraction = 1f
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = MediaDetailHorizontalPadding)
|
||||||
) {
|
) {
|
||||||
TvSeriesHeroSection(
|
TvSeriesHeroSection(
|
||||||
series = series,
|
series = series,
|
||||||
nextUpEpisode = nextUpEpisode,
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(18.dp))
|
||||||
|
TvSeasonTabs(
|
||||||
|
seasons = series.seasons,
|
||||||
|
selectedSeason = selectedSeason,
|
||||||
|
firstItemFocusRequester = firstContentFocusRequester,
|
||||||
|
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||||
|
onSelect = { selectedSeason = it },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
TvEpisodeCarousel(
|
||||||
|
episodes = selectedSeason.episodes,
|
||||||
onPlayEpisode = { onPlayEpisode(it.id) },
|
onPlayEpisode = { onPlayEpisode(it.id) },
|
||||||
playFocusRequester = playFocusRequester,
|
focusedEpisodeId = nextUpEpisode?.id,
|
||||||
firstContentFocusRequester = firstContentFocusRequester,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item(key = "series-overview") {
|
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
MediaDetailOverviewSection(
|
|
||||||
synopsis = series.synopsis,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item(key = "series-season-tabs") {
|
|
||||||
TvSeasonTabs(
|
|
||||||
seasons = series.seasons,
|
|
||||||
selectedSeason = selectedSeason,
|
|
||||||
firstItemFocusRequester = firstContentFocusRequester,
|
|
||||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
|
||||||
onSelect = { selectedSeason = it },
|
|
||||||
modifier = it
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item(key = "series-episodes") {
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
TvEpisodeCarousel(
|
|
||||||
episodes = selectedSeason.episodes,
|
|
||||||
modifier = it
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (series.cast.isNotEmpty()) {
|
|
||||||
item(key = "series-cast") {
|
|
||||||
Column(modifier = it) {
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
MediaDetailSectionTitle(text = "Cast")
|
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
|
||||||
CastRow(cast = series.cast)
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Series.defaultSeason(): Season {
|
private fun Series.defaultSeason(nextUpEpisode: Episode?): Season {
|
||||||
|
if (nextUpEpisode != null) {
|
||||||
|
seasons.firstOrNull { it.id == nextUpEpisode.seasonId }?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
for (season in seasons) {
|
for (season in seasons) {
|
||||||
if (season.episodes.any { !it.watched }) {
|
if (season.episodes.any { !it.watched }) {
|
||||||
return season
|
return season
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package hu.bbara.purefin.ui.screen.series.components
|
package hu.bbara.purefin.ui.screen.series.components
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
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.gestures.LocalBringIntoViewSpec
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
@@ -16,9 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
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.size
|
||||||
import androidx.compose.foundation.layout.sizeIn
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
@@ -30,16 +31,17 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -50,8 +52,6 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import hu.bbara.purefin.feature.content.series.SeriesViewModel
|
|
||||||
import hu.bbara.purefin.image.ArtworkKind
|
import hu.bbara.purefin.image.ArtworkKind
|
||||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
import hu.bbara.purefin.image.ImageUrlBuilder
|
||||||
import hu.bbara.purefin.model.CastMember
|
import hu.bbara.purefin.model.CastMember
|
||||||
@@ -60,14 +60,13 @@ import hu.bbara.purefin.model.Season
|
|||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||||
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
||||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
import hu.bbara.purefin.ui.screen.home.components.TvHomeRowBringIntoViewSpec
|
||||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
import java.util.UUID
|
||||||
|
|
||||||
internal const val SeriesPlayButtonTag = "series-play-button"
|
|
||||||
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
|
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
|
||||||
|
internal const val SeriesNextUpEpisodeCardTag = "series-next-up-episode-card"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TvSeriesMetaChips(series: Series) {
|
internal fun TvSeriesMetaChips(series: Series) {
|
||||||
@@ -152,26 +151,56 @@ private fun TvSeasonTab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TvEpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modifier) {
|
internal fun TvEpisodeCarousel(
|
||||||
|
episodes: List<Episode>,
|
||||||
|
onPlayEpisode: (Episode) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
focusedEpisodeId: UUID? = null
|
||||||
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val focusedEpisodeFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(episodes) {
|
LaunchedEffect(episodes, focusedEpisodeId) {
|
||||||
|
val focusedEpisodeIndex = focusedEpisodeId?.let { id ->
|
||||||
|
episodes.indexOfFirst { it.id == id }
|
||||||
|
} ?: -1
|
||||||
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }.let { if (it == -1) 0 else it }
|
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }.let { if (it == -1) 0 else it }
|
||||||
if (firstUnwatchedIndex != 0) {
|
val targetIndex = focusedEpisodeIndex.takeIf { it >= 0 } ?: firstUnwatchedIndex
|
||||||
listState.animateScrollToItem(firstUnwatchedIndex)
|
|
||||||
|
if (targetIndex != 0) {
|
||||||
|
listState.scrollToItem(targetIndex)
|
||||||
} else {
|
} else {
|
||||||
listState.scrollToItem(0)
|
listState.scrollToItem(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (focusedEpisodeIndex >= 0) {
|
||||||
|
withFrameNanos { }
|
||||||
|
focusedEpisodeFocusRequester.requestFocus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyRow(
|
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
|
||||||
state = listState,
|
LazyRow(
|
||||||
modifier = modifier,
|
state = listState,
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
modifier = modifier,
|
||||||
) {
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||||
items(episodes, key = { episode -> episode.id }) { episode ->
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
TvEpisodeCard(episode = episode)
|
) {
|
||||||
|
items(episodes, key = { episode -> episode.id }) { episode ->
|
||||||
|
TvEpisodeCard(
|
||||||
|
episode = episode,
|
||||||
|
onPlayEpisode = { onPlayEpisode(episode) },
|
||||||
|
modifier = if (episode.id == focusedEpisodeId) {
|
||||||
|
Modifier
|
||||||
|
.focusRequester(focusedEpisodeFocusRequester)
|
||||||
|
.testTag(SeriesNextUpEpisodeCardTag)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,10 +208,6 @@ internal fun TvEpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Mod
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun TvSeriesHeroSection(
|
internal fun TvSeriesHeroSection(
|
||||||
series: Series,
|
series: Series,
|
||||||
nextUpEpisode: Episode?,
|
|
||||||
onPlayEpisode: (Episode) -> Unit,
|
|
||||||
playFocusRequester: FocusRequester,
|
|
||||||
firstContentFocusRequester: FocusRequester,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
@@ -190,81 +215,45 @@ internal fun TvSeriesHeroSection(
|
|||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.padding(top = 24.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.widthIn(max = 760.dp)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = series.name,
|
text = series.name,
|
||||||
color = scheme.onBackground,
|
color = scheme.onBackground,
|
||||||
fontSize = 42.sp,
|
fontSize = 24.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
lineHeight = 48.sp,
|
lineHeight = 28.sp,
|
||||||
maxLines = 2,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(18.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
TvSeriesMetaChips(series = series)
|
TvSeriesMetaChips(series = series)
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
if (nextUpEpisode != null) {
|
Text(
|
||||||
Text(
|
text = "Overview",
|
||||||
text = nextUpEpisode.heroStatusText(),
|
color = scheme.onBackground,
|
||||||
color = scheme.primary,
|
fontSize = 13.sp,
|
||||||
fontSize = 18.sp,
|
fontWeight = FontWeight.Bold
|
||||||
fontWeight = FontWeight.SemiBold,
|
)
|
||||||
maxLines = 1,
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
overflow = TextOverflow.Ellipsis
|
Text(
|
||||||
)
|
text = series.synopsis,
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
color = mutedStrong,
|
||||||
Text(
|
fontSize = 12.sp,
|
||||||
text = nextUpEpisode.title,
|
lineHeight = 16.sp,
|
||||||
color = scheme.onBackground,
|
maxLines = 3,
|
||||||
fontSize = 22.sp,
|
overflow = TextOverflow.Ellipsis,
|
||||||
fontWeight = FontWeight.SemiBold,
|
modifier = Modifier.fillMaxWidth(0.4f)
|
||||||
maxLines = 2,
|
)
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
Text(
|
|
||||||
text = "Episode ${nextUpEpisode.index} • ${nextUpEpisode.runtime}",
|
|
||||||
color = mutedStrong,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
MediaResumeButton(
|
|
||||||
text = mediaPlayButtonText(nextUpEpisode.progress, nextUpEpisode.watched),
|
|
||||||
progress = mediaPlaybackProgress(nextUpEpisode.progress),
|
|
||||||
onClick = { onPlayEpisode(nextUpEpisode) },
|
|
||||||
modifier = Modifier
|
|
||||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
|
||||||
.focusRequester(playFocusRequester)
|
|
||||||
.focusProperties { down = firstContentFocusRequester }
|
|
||||||
.testTag(SeriesPlayButtonTag),
|
|
||||||
focusedScale = 1.08f,
|
|
||||||
focusHaloColor = scheme.primary.copy(alpha = 0.22f),
|
|
||||||
focusBorderWidth = 3.dp,
|
|
||||||
focusBorderColor = scheme.onBackground,
|
|
||||||
overlayBorderWidth = 2.dp,
|
|
||||||
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
|
|
||||||
focusable = true
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = "Choose a season below to start watching.",
|
|
||||||
color = mutedStrong,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TvEpisodeCard(
|
private fun TvEpisodeCard(
|
||||||
viewModel: SeriesViewModel = hiltViewModel(),
|
episode: Episode,
|
||||||
episode: Episode
|
onPlayEpisode: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||||
@@ -272,15 +261,11 @@ private fun TvEpisodeCard(
|
|||||||
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
|
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.width(260.dp)
|
.width(260.dp)
|
||||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
.clickable { viewModel.onSelectEpisode(
|
.clickable { onPlayEpisode() },
|
||||||
seriesId = episode.seriesId,
|
|
||||||
seasonId = episode.seasonId,
|
|
||||||
episodeId = episode.id
|
|
||||||
) },
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -375,7 +360,3 @@ internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
|
|||||||
// roleSize = 10.sp
|
// roleSize = 10.sp
|
||||||
// )
|
// )
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Episode.heroStatusText(): String {
|
|
||||||
return if ((progress ?: 0.0) > 0.0 && !watched) "Continue Watching" else "Up Next"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -35,6 +36,7 @@ import androidx.compose.ui.graphics.drawscope.clipRect
|
|||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
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 androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
@@ -50,7 +52,10 @@ fun MediaResumeButton(
|
|||||||
focusBorderColor: Color = Color.Transparent,
|
focusBorderColor: Color = Color.Transparent,
|
||||||
overlayBorderWidth: Dp = 0.dp,
|
overlayBorderWidth: Dp = 0.dp,
|
||||||
overlayBorderColor: Color = Color.Transparent,
|
overlayBorderColor: Color = Color.Transparent,
|
||||||
focusable: Boolean = false
|
focusable: Boolean = false,
|
||||||
|
height: Dp = 52.dp,
|
||||||
|
textSize: TextUnit = 16.sp,
|
||||||
|
iconSize: Dp = 24.dp
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val primaryColor = scheme.primary
|
val primaryColor = scheme.primary
|
||||||
@@ -73,7 +78,7 @@ fun MediaResumeButton(
|
|||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
.height(52.dp)
|
.height(height)
|
||||||
.background(haloColor, shape)
|
.background(haloColor, shape)
|
||||||
.border(
|
.border(
|
||||||
width = if (isFocused) focusBorderWidth else 0.dp,
|
width = if (isFocused) focusBorderWidth else 0.dp,
|
||||||
@@ -91,7 +96,12 @@ fun MediaResumeButton(
|
|||||||
.background(onPrimaryColor),
|
.background(onPrimaryColor),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
ResumeButtonContent(text = text, color = primaryColor)
|
ResumeButtonContent(
|
||||||
|
text = text,
|
||||||
|
color = primaryColor,
|
||||||
|
textSize = textSize,
|
||||||
|
iconSize = iconSize
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -111,7 +121,12 @@ fun MediaResumeButton(
|
|||||||
.background(primaryColor),
|
.background(primaryColor),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
ResumeButtonContent(text = text, color = onPrimaryColor)
|
ResumeButtonContent(
|
||||||
|
text = text,
|
||||||
|
color = onPrimaryColor,
|
||||||
|
textSize = textSize,
|
||||||
|
iconSize = iconSize
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFocused && overlayBorderWidth > 0.dp) {
|
if (isFocused && overlayBorderWidth > 0.dp) {
|
||||||
@@ -125,13 +140,23 @@ fun MediaResumeButton(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ResumeButtonContent(text: String, color: Color) {
|
private fun ResumeButtonContent(
|
||||||
|
text: String,
|
||||||
|
color: Color,
|
||||||
|
textSize: TextUnit,
|
||||||
|
iconSize: Dp
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
Text(text, color = color, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
Text(text, color = color, fontWeight = FontWeight.Bold, fontSize = textSize)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Icon(Icons.Filled.PlayArrow, null, tint = color)
|
Icon(
|
||||||
|
imageVector = Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier.size(iconSize)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ class AppViewModel @Inject constructor(
|
|||||||
when (mediaUiModel) {
|
when (mediaUiModel) {
|
||||||
is MovieUiModel -> onMovieSelected(mediaUiModel.id)
|
is MovieUiModel -> onMovieSelected(mediaUiModel.id)
|
||||||
is SeriesUiModel -> onSeriesSelected(mediaUiModel.id)
|
is SeriesUiModel -> onSeriesSelected(mediaUiModel.id)
|
||||||
is EpisodeUiModel -> onEpisodeSelected(mediaUiModel.seriesId, mediaUiModel.seasonId, mediaUiModel.id)
|
is EpisodeUiModel -> onSeriesSelected(mediaUiModel.seriesId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
|||||||
import hu.bbara.purefin.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -105,7 +106,14 @@ class InMemoryMediaRepository @Inject constructor(
|
|||||||
|
|
||||||
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
|
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
ensureSeriesContentLoaded(seriesId)
|
try {
|
||||||
|
ensureSeriesContentLoaded(seriesId)
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.e("InMemoryMediaRepository", "Failed to load content for series $seriesId", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return seriesState.map { it[seriesId] }
|
return seriesState.map { it[seriesId] }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import hu.bbara.purefin.data.PlaybackMethod
|
import hu.bbara.purefin.data.PlaybackMethod
|
||||||
import hu.bbara.purefin.data.PlaybackReportContext
|
import hu.bbara.purefin.data.PlaybackReportContext
|
||||||
import hu.bbara.purefin.data.UserSessionRepository
|
import hu.bbara.purefin.data.UserSessionRepository
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -85,7 +86,9 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun configureFromSession(): Boolean = withContext(Dispatchers.IO) {
|
suspend fun configureFromSession(): Boolean = withContext(Dispatchers.IO) {
|
||||||
ensureConfigured()
|
logApiFailure("configureFromSession") {
|
||||||
|
ensureConfigured()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun authenticate(
|
suspend fun authenticate(
|
||||||
@@ -93,224 +96,254 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
): AuthenticationResult? = withContext(Dispatchers.IO) {
|
): AuthenticationResult? = withContext(Dispatchers.IO) {
|
||||||
val trimmedUrl = url.trim()
|
logApiFailure("authenticate") {
|
||||||
if (trimmedUrl.isBlank()) {
|
val trimmedUrl = url.trim()
|
||||||
return@withContext null
|
if (trimmedUrl.isBlank()) {
|
||||||
}
|
return@logApiFailure null
|
||||||
|
}
|
||||||
|
|
||||||
api.update(baseUrl = trimmedUrl)
|
api.update(baseUrl = trimmedUrl)
|
||||||
api.userApi.authenticateUserByName(username = username, password = password).content
|
api.userApi.authenticateUserByName(username = username, password = password).content
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
logApiFailure("getLibraries") {
|
||||||
return@withContext emptyList()
|
if (!ensureConfigured()) {
|
||||||
|
return@logApiFailure emptyList()
|
||||||
|
}
|
||||||
|
val response = api.userViewsApi.getUserViews(
|
||||||
|
userId = getUserId(),
|
||||||
|
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
|
||||||
|
includeHidden = false,
|
||||||
|
)
|
||||||
|
Log.d("getLibraries", response.content.toString())
|
||||||
|
response.content.items
|
||||||
}
|
}
|
||||||
val response = api.userViewsApi.getUserViews(
|
|
||||||
userId = getUserId(),
|
|
||||||
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
|
|
||||||
includeHidden = false,
|
|
||||||
)
|
|
||||||
Log.d("getLibraries", response.content.toString())
|
|
||||||
response.content.items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
logApiFailure("getLibraryContent($libraryId)") {
|
||||||
return@withContext emptyList()
|
if (!ensureConfigured()) {
|
||||||
|
return@logApiFailure emptyList()
|
||||||
|
}
|
||||||
|
val getItemsRequest = GetItemsRequest(
|
||||||
|
userId = getUserId(),
|
||||||
|
enableImages = true,
|
||||||
|
parentId = libraryId,
|
||||||
|
fields = itemFields,
|
||||||
|
enableUserData = true,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||||
|
recursive = true,
|
||||||
|
)
|
||||||
|
val response = api.itemsApi.getItems(getItemsRequest)
|
||||||
|
Log.d("getLibraryContent", response.content.toString())
|
||||||
|
response.content.items
|
||||||
}
|
}
|
||||||
val getItemsRequest = GetItemsRequest(
|
|
||||||
userId = getUserId(),
|
|
||||||
enableImages = true,
|
|
||||||
parentId = libraryId,
|
|
||||||
fields = itemFields,
|
|
||||||
enableUserData = true,
|
|
||||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
|
||||||
recursive = true,
|
|
||||||
)
|
|
||||||
val response = api.itemsApi.getItems(getItemsRequest)
|
|
||||||
Log.d("getLibraryContent", response.content.toString())
|
|
||||||
response.content.items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getSuggestions(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
suspend fun getSuggestions(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
logApiFailure("getSuggestions") {
|
||||||
return@withContext emptyList()
|
if (!ensureConfigured()) {
|
||||||
|
return@logApiFailure emptyList()
|
||||||
|
}
|
||||||
|
val userId = getUserId() ?: return@logApiFailure emptyList()
|
||||||
|
val response = api.suggestionsApi.getSuggestions(
|
||||||
|
userId = userId,
|
||||||
|
mediaType = listOf(MediaType.VIDEO),
|
||||||
|
type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||||
|
limit = 8,
|
||||||
|
enableTotalRecordCount = true,
|
||||||
|
)
|
||||||
|
Log.d("getSuggestions", response.content.toString())
|
||||||
|
response.content.items
|
||||||
}
|
}
|
||||||
val userId = getUserId() ?: return@withContext emptyList()
|
|
||||||
val response = api.suggestionsApi.getSuggestions(
|
|
||||||
userId = userId,
|
|
||||||
mediaType = listOf(MediaType.VIDEO),
|
|
||||||
type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
|
||||||
limit = 8,
|
|
||||||
enableTotalRecordCount = true,
|
|
||||||
)
|
|
||||||
Log.d("getSuggestions", response.content.toString())
|
|
||||||
response.content.items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getContinueWatching(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
suspend fun getContinueWatching(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
logApiFailure("getContinueWatching") {
|
||||||
return@withContext emptyList()
|
if (!ensureConfigured()) {
|
||||||
|
return@logApiFailure emptyList()
|
||||||
|
}
|
||||||
|
val userId = getUserId() ?: return@logApiFailure emptyList()
|
||||||
|
val getResumeItemsRequest = GetResumeItemsRequest(
|
||||||
|
userId = userId,
|
||||||
|
fields = itemFields,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
|
||||||
|
enableUserData = true,
|
||||||
|
startIndex = 0,
|
||||||
|
)
|
||||||
|
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
||||||
|
Log.d("getContinueWatching", response.content.toString())
|
||||||
|
response.content.items
|
||||||
}
|
}
|
||||||
val userId = getUserId() ?: return@withContext emptyList()
|
|
||||||
val getResumeItemsRequest = GetResumeItemsRequest(
|
|
||||||
userId = userId,
|
|
||||||
fields = itemFields,
|
|
||||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
|
|
||||||
enableUserData = true,
|
|
||||||
startIndex = 0,
|
|
||||||
)
|
|
||||||
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
|
||||||
Log.d("getContinueWatching", response.content.toString())
|
|
||||||
response.content.items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getNextUpEpisodes(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
suspend fun getNextUpEpisodes(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
logApiFailure("getNextUpEpisodes") {
|
||||||
throw IllegalStateException("Not configured")
|
if (!ensureConfigured()) {
|
||||||
|
throw IllegalStateException("Not configured")
|
||||||
|
}
|
||||||
|
val getNextUpRequest = GetNextUpRequest(
|
||||||
|
userId = getUserId(),
|
||||||
|
fields = itemFields,
|
||||||
|
enableResumable = false,
|
||||||
|
)
|
||||||
|
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
|
||||||
|
Log.d("getNextUpEpisodes", result.content.toString())
|
||||||
|
result.content.items
|
||||||
}
|
}
|
||||||
val getNextUpRequest = GetNextUpRequest(
|
|
||||||
userId = getUserId(),
|
|
||||||
fields = itemFields,
|
|
||||||
enableResumable = false,
|
|
||||||
)
|
|
||||||
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
|
|
||||||
Log.d("getNextUpEpisodes", result.content.toString())
|
|
||||||
result.content.items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
logApiFailure("getLatestFromLibrary($libraryId)") {
|
||||||
return@withContext emptyList()
|
if (!ensureConfigured()) {
|
||||||
|
return@logApiFailure emptyList()
|
||||||
|
}
|
||||||
|
val response = api.userLibraryApi.getLatestMedia(
|
||||||
|
userId = getUserId(),
|
||||||
|
parentId = libraryId,
|
||||||
|
fields = itemFields,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
|
||||||
|
limit = 10,
|
||||||
|
)
|
||||||
|
Log.d("getLatestFromLibrary", response.content.toString())
|
||||||
|
response.content
|
||||||
}
|
}
|
||||||
val response = api.userLibraryApi.getLatestMedia(
|
|
||||||
userId = getUserId(),
|
|
||||||
parentId = libraryId,
|
|
||||||
fields = itemFields,
|
|
||||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
|
|
||||||
limit = 10,
|
|
||||||
)
|
|
||||||
Log.d("getLatestFromLibrary", response.content.toString())
|
|
||||||
response.content
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getItemInfo(mediaId: UUID): BaseItemDto? = withContext(Dispatchers.IO) {
|
suspend fun getItemInfo(mediaId: UUID): BaseItemDto? = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
logApiFailure("getItemInfo($mediaId)") {
|
||||||
return@withContext null
|
if (!ensureConfigured()) {
|
||||||
|
return@logApiFailure null
|
||||||
|
}
|
||||||
|
val result = api.userLibraryApi.getItem(itemId = mediaId, userId = getUserId())
|
||||||
|
Log.d("getItemInfo", result.content.toString())
|
||||||
|
result.content
|
||||||
}
|
}
|
||||||
val result = api.userLibraryApi.getItem(itemId = mediaId, userId = getUserId())
|
|
||||||
Log.d("getItemInfo", result.content.toString())
|
|
||||||
result.content
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getSeasons(seriesId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
suspend fun getSeasons(seriesId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
logApiFailure("getSeasons($seriesId)") {
|
||||||
return@withContext emptyList()
|
if (!ensureConfigured()) {
|
||||||
|
return@logApiFailure emptyList()
|
||||||
|
}
|
||||||
|
val result = api.tvShowsApi.getSeasons(
|
||||||
|
userId = getUserId(),
|
||||||
|
seriesId = seriesId,
|
||||||
|
fields = itemFields,
|
||||||
|
enableUserData = true,
|
||||||
|
)
|
||||||
|
Log.d("getSeasons", result.content.toString())
|
||||||
|
result.content.items
|
||||||
}
|
}
|
||||||
val result = api.tvShowsApi.getSeasons(
|
|
||||||
userId = getUserId(),
|
|
||||||
seriesId = seriesId,
|
|
||||||
fields = itemFields,
|
|
||||||
enableUserData = true,
|
|
||||||
)
|
|
||||||
Log.d("getSeasons", result.content.toString())
|
|
||||||
result.content.items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getEpisodesInSeason(seriesId: UUID, seasonId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
suspend fun getEpisodesInSeason(seriesId: UUID, seasonId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
logApiFailure("getEpisodesInSeason(series=$seriesId, season=$seasonId)") {
|
||||||
return@withContext emptyList()
|
if (!ensureConfigured()) {
|
||||||
|
return@logApiFailure emptyList()
|
||||||
|
}
|
||||||
|
val result = api.tvShowsApi.getEpisodes(
|
||||||
|
userId = getUserId(),
|
||||||
|
seriesId = seriesId,
|
||||||
|
seasonId = seasonId,
|
||||||
|
fields = itemFields,
|
||||||
|
enableUserData = true,
|
||||||
|
)
|
||||||
|
Log.d("getEpisodesInSeason", result.content.toString())
|
||||||
|
result.content.items
|
||||||
}
|
}
|
||||||
val result = api.tvShowsApi.getEpisodes(
|
|
||||||
userId = getUserId(),
|
|
||||||
seriesId = seriesId,
|
|
||||||
seasonId = seasonId,
|
|
||||||
fields = itemFields,
|
|
||||||
enableUserData = true,
|
|
||||||
)
|
|
||||||
Log.d("getEpisodesInSeason", result.content.toString())
|
|
||||||
result.content.items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getNextEpisodes(episodeId: UUID, count: Int): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
suspend fun getNextEpisodes(episodeId: UUID, count: Int): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
logApiFailure("getNextEpisodes($episodeId, count=$count)") {
|
||||||
return@withContext emptyList()
|
if (!ensureConfigured()) {
|
||||||
|
return@logApiFailure emptyList()
|
||||||
|
}
|
||||||
|
val episodeInfo = getItemInfo(episodeId) ?: return@logApiFailure emptyList()
|
||||||
|
val seriesId = episodeInfo.seriesId ?: return@logApiFailure emptyList()
|
||||||
|
val nextUpEpisodesResult = api.tvShowsApi.getEpisodes(
|
||||||
|
userId = getUserId(),
|
||||||
|
seriesId = seriesId,
|
||||||
|
enableUserData = true,
|
||||||
|
startItemId = episodeId,
|
||||||
|
limit = count,
|
||||||
|
)
|
||||||
|
val nextUpEpisodes = nextUpEpisodesResult.content.items
|
||||||
|
Log.d("getNextEpisodes", nextUpEpisodes.toString())
|
||||||
|
nextUpEpisodes
|
||||||
}
|
}
|
||||||
val episodeInfo = getItemInfo(episodeId) ?: return@withContext emptyList()
|
|
||||||
val seriesId = episodeInfo.seriesId ?: return@withContext emptyList()
|
|
||||||
val nextUpEpisodesResult = api.tvShowsApi.getEpisodes(
|
|
||||||
userId = getUserId(),
|
|
||||||
seriesId = seriesId,
|
|
||||||
enableUserData = true,
|
|
||||||
startItemId = episodeId,
|
|
||||||
limit = count,
|
|
||||||
)
|
|
||||||
val nextUpEpisodes = nextUpEpisodesResult.content.items
|
|
||||||
Log.d("getNextEpisodes", nextUpEpisodes.toString())
|
|
||||||
nextUpEpisodes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getGenres(id: UUID? = null) : List<BaseItemDto> = withContext(Dispatchers.IO) {
|
suspend fun getGenres(id: UUID? = null) : List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
logApiFailure("getGenres($id)") {
|
||||||
return@withContext emptyList()
|
if (!ensureConfigured()) {
|
||||||
|
return@logApiFailure emptyList()
|
||||||
|
}
|
||||||
|
val result = api.genresApi.getGenres(
|
||||||
|
userId = getUserId(),
|
||||||
|
parentId = id,
|
||||||
|
)
|
||||||
|
Log.d("getGenres", result.toString())
|
||||||
|
result.content.items
|
||||||
}
|
}
|
||||||
val result = api.genresApi.getGenres(
|
|
||||||
userId = getUserId(),
|
|
||||||
parentId = id,
|
|
||||||
)
|
|
||||||
Log.d("getGenres", result.toString())
|
|
||||||
result.content.items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
|
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
logApiFailure("getMediaSources($mediaId)") {
|
||||||
return@withContext emptyList()
|
if (!ensureConfigured()) {
|
||||||
|
return@logApiFailure emptyList()
|
||||||
|
}
|
||||||
|
val result = api.mediaInfoApi.getPostedPlaybackInfo(
|
||||||
|
mediaId,
|
||||||
|
PlaybackInfoDto(
|
||||||
|
userId = getUserId(),
|
||||||
|
deviceProfile = null,
|
||||||
|
maxStreamingBitrate = 100_000_000,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Log.d("getMediaSources", result.toString())
|
||||||
|
result.content.mediaSources
|
||||||
}
|
}
|
||||||
val result = api.mediaInfoApi.getPostedPlaybackInfo(
|
|
||||||
mediaId,
|
|
||||||
PlaybackInfoDto(
|
|
||||||
userId = getUserId(),
|
|
||||||
deviceProfile = null,
|
|
||||||
maxStreamingBitrate = 100_000_000,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
Log.d("getMediaSources", result.toString())
|
|
||||||
result.content.mediaSources
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMediaSegments(mediaId: UUID) : List<MediaSegmentDto> = withContext(Dispatchers.IO) {
|
suspend fun getMediaSegments(mediaId: UUID) : List<MediaSegmentDto> = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
logApiFailure("getMediaSegments($mediaId)") {
|
||||||
return@withContext emptyList()
|
if (!ensureConfigured()) {
|
||||||
|
return@logApiFailure emptyList()
|
||||||
|
}
|
||||||
|
val result = api.mediaSegmentsApi.getItemSegments(
|
||||||
|
itemId = mediaId,
|
||||||
|
//includeSegmentTypes = listOf(MediaSegmentType.INTRO)
|
||||||
|
)
|
||||||
|
Log.d("getMediaSegments", result.toString())
|
||||||
|
result.content.items
|
||||||
}
|
}
|
||||||
val result = api.mediaSegmentsApi.getItemSegments(
|
|
||||||
itemId = mediaId,
|
|
||||||
//includeSegmentTypes = listOf(MediaSegmentType.INTRO)
|
|
||||||
)
|
|
||||||
Log.d("getMediaSegments", result.toString())
|
|
||||||
result.content.items
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getPlaybackInfo(
|
suspend fun getPlaybackInfo(
|
||||||
mediaId: UUID,
|
mediaId: UUID,
|
||||||
deviceProfile: DeviceProfile,
|
deviceProfile: DeviceProfile,
|
||||||
): PlaybackInfoResponse? = withContext(Dispatchers.IO) {
|
): PlaybackInfoResponse? = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
logApiFailure("getPlaybackInfo($mediaId)") {
|
||||||
return@withContext null
|
if (!ensureConfigured()) {
|
||||||
|
return@logApiFailure null
|
||||||
|
}
|
||||||
|
api.mediaInfoApi.getPostedPlaybackInfo(
|
||||||
|
mediaId,
|
||||||
|
PlaybackInfoDto(
|
||||||
|
userId = getUserId(),
|
||||||
|
deviceProfile = deviceProfile,
|
||||||
|
enableDirectPlay = true,
|
||||||
|
enableDirectStream = true,
|
||||||
|
enableTranscoding = true,
|
||||||
|
allowVideoStreamCopy = true,
|
||||||
|
allowAudioStreamCopy = true,
|
||||||
|
autoOpenLiveStream = false,
|
||||||
|
),
|
||||||
|
).content
|
||||||
}
|
}
|
||||||
api.mediaInfoApi.getPostedPlaybackInfo(
|
|
||||||
mediaId,
|
|
||||||
PlaybackInfoDto(
|
|
||||||
userId = getUserId(),
|
|
||||||
deviceProfile = deviceProfile,
|
|
||||||
enableDirectPlay = true,
|
|
||||||
enableDirectStream = true,
|
|
||||||
enableTranscoding = true,
|
|
||||||
allowVideoStreamCopy = true,
|
|
||||||
allowAudioStreamCopy = true,
|
|
||||||
autoOpenLiveStream = false,
|
|
||||||
),
|
|
||||||
).content
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getVideoStreamUrl(
|
fun getVideoStreamUrl(
|
||||||
@@ -320,21 +353,28 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
tag: String? = null,
|
tag: String? = null,
|
||||||
playSessionId: String? = null,
|
playSessionId: String? = null,
|
||||||
liveStreamId: String? = null,
|
liveStreamId: String? = null,
|
||||||
): String = api.videosApi.getVideoStreamUrl(
|
): String = try {
|
||||||
itemId = itemId,
|
api.videosApi.getVideoStreamUrl(
|
||||||
container = container,
|
itemId = itemId,
|
||||||
mediaSourceId = mediaSourceId,
|
container = container,
|
||||||
static = true,
|
mediaSourceId = mediaSourceId,
|
||||||
tag = tag,
|
static = true,
|
||||||
playSessionId = playSessionId,
|
tag = tag,
|
||||||
liveStreamId = liveStreamId,
|
playSessionId = playSessionId,
|
||||||
)
|
liveStreamId = liveStreamId,
|
||||||
|
)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.e(TAG, "getVideoStreamUrl($itemId) failed", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getPublicSystemInfoVersion(): String? = withContext(Dispatchers.IO) {
|
suspend fun getPublicSystemInfoVersion(): String? = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) {
|
logApiFailure("getPublicSystemInfoVersion") {
|
||||||
return@withContext null
|
if (!ensureConfigured()) {
|
||||||
|
return@logApiFailure null
|
||||||
|
}
|
||||||
|
SystemApi(api).getPublicSystemInfo().content.version
|
||||||
}
|
}
|
||||||
SystemApi(api).getPublicSystemInfo().content.version
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun reportPlaybackStart(
|
suspend fun reportPlaybackStart(
|
||||||
@@ -342,24 +382,26 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
positionTicks: Long = 0L,
|
positionTicks: Long = 0L,
|
||||||
reportContext: PlaybackReportContext,
|
reportContext: PlaybackReportContext,
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) return@withContext
|
logApiFailure("reportPlaybackStart($itemId)") {
|
||||||
api.playStateApi.reportPlaybackStart(
|
if (!ensureConfigured()) return@logApiFailure
|
||||||
PlaybackStartInfo(
|
api.playStateApi.reportPlaybackStart(
|
||||||
itemId = itemId,
|
PlaybackStartInfo(
|
||||||
positionTicks = positionTicks,
|
itemId = itemId,
|
||||||
canSeek = true,
|
positionTicks = positionTicks,
|
||||||
isPaused = false,
|
canSeek = true,
|
||||||
isMuted = false,
|
isPaused = false,
|
||||||
mediaSourceId = reportContext.mediaSourceId,
|
isMuted = false,
|
||||||
audioStreamIndex = reportContext.audioStreamIndex,
|
mediaSourceId = reportContext.mediaSourceId,
|
||||||
subtitleStreamIndex = reportContext.subtitleStreamIndex,
|
audioStreamIndex = reportContext.audioStreamIndex,
|
||||||
liveStreamId = reportContext.liveStreamId,
|
subtitleStreamIndex = reportContext.subtitleStreamIndex,
|
||||||
playSessionId = reportContext.playSessionId,
|
liveStreamId = reportContext.liveStreamId,
|
||||||
playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
|
playSessionId = reportContext.playSessionId,
|
||||||
repeatMode = RepeatMode.REPEAT_NONE,
|
playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
|
||||||
playbackOrder = PlaybackOrder.DEFAULT,
|
repeatMode = RepeatMode.REPEAT_NONE,
|
||||||
),
|
playbackOrder = PlaybackOrder.DEFAULT,
|
||||||
)
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun reportPlaybackProgress(
|
suspend fun reportPlaybackProgress(
|
||||||
@@ -368,24 +410,26 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
isPaused: Boolean,
|
isPaused: Boolean,
|
||||||
reportContext: PlaybackReportContext,
|
reportContext: PlaybackReportContext,
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) return@withContext
|
logApiFailure("reportPlaybackProgress($itemId)") {
|
||||||
api.playStateApi.reportPlaybackProgress(
|
if (!ensureConfigured()) return@logApiFailure
|
||||||
PlaybackProgressInfo(
|
api.playStateApi.reportPlaybackProgress(
|
||||||
itemId = itemId,
|
PlaybackProgressInfo(
|
||||||
positionTicks = positionTicks,
|
itemId = itemId,
|
||||||
canSeek = true,
|
positionTicks = positionTicks,
|
||||||
isPaused = isPaused,
|
canSeek = true,
|
||||||
isMuted = false,
|
isPaused = isPaused,
|
||||||
mediaSourceId = reportContext.mediaSourceId,
|
isMuted = false,
|
||||||
audioStreamIndex = reportContext.audioStreamIndex,
|
mediaSourceId = reportContext.mediaSourceId,
|
||||||
subtitleStreamIndex = reportContext.subtitleStreamIndex,
|
audioStreamIndex = reportContext.audioStreamIndex,
|
||||||
liveStreamId = reportContext.liveStreamId,
|
subtitleStreamIndex = reportContext.subtitleStreamIndex,
|
||||||
playSessionId = reportContext.playSessionId,
|
liveStreamId = reportContext.liveStreamId,
|
||||||
playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
|
playSessionId = reportContext.playSessionId,
|
||||||
repeatMode = RepeatMode.REPEAT_NONE,
|
playMethod = reportContext.playMethod.toJellyfinPlayMethod(),
|
||||||
playbackOrder = PlaybackOrder.DEFAULT,
|
repeatMode = RepeatMode.REPEAT_NONE,
|
||||||
),
|
playbackOrder = PlaybackOrder.DEFAULT,
|
||||||
)
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun reportPlaybackStopped(
|
suspend fun reportPlaybackStopped(
|
||||||
@@ -393,17 +437,30 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
positionTicks: Long,
|
positionTicks: Long,
|
||||||
reportContext: PlaybackReportContext,
|
reportContext: PlaybackReportContext,
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(Dispatchers.IO) {
|
||||||
if (!ensureConfigured()) return@withContext
|
logApiFailure("reportPlaybackStopped($itemId)") {
|
||||||
api.playStateApi.reportPlaybackStopped(
|
if (!ensureConfigured()) return@logApiFailure
|
||||||
PlaybackStopInfo(
|
api.playStateApi.reportPlaybackStopped(
|
||||||
itemId = itemId,
|
PlaybackStopInfo(
|
||||||
positionTicks = positionTicks,
|
itemId = itemId,
|
||||||
mediaSourceId = reportContext.mediaSourceId,
|
positionTicks = positionTicks,
|
||||||
liveStreamId = reportContext.liveStreamId,
|
mediaSourceId = reportContext.mediaSourceId,
|
||||||
playSessionId = reportContext.playSessionId,
|
liveStreamId = reportContext.liveStreamId,
|
||||||
failed = false,
|
playSessionId = reportContext.playSessionId,
|
||||||
),
|
failed = false,
|
||||||
)
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> logApiFailure(operation: String, block: suspend () -> T): T {
|
||||||
|
return try {
|
||||||
|
block()
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.e(TAG, "$operation failed", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun PlaybackMethod.toJellyfinPlayMethod(): PlayMethod = when (this) {
|
private fun PlaybackMethod.toJellyfinPlayMethod(): PlayMethod = when (this) {
|
||||||
@@ -411,4 +468,8 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
PlaybackMethod.DIRECT_STREAM -> PlayMethod.DIRECT_STREAM
|
PlaybackMethod.DIRECT_STREAM -> PlayMethod.DIRECT_STREAM
|
||||||
PlaybackMethod.TRANSCODE -> PlayMethod.TRANSCODE
|
PlaybackMethod.TRANSCODE -> PlayMethod.TRANSCODE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "JellyfinApiClient"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user