Compare commits

...

5 Commits

12 changed files with 563 additions and 514 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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