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.ui.theme.AppTheme
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.Rule
import org.junit.Test
@@ -30,7 +30,7 @@ class SeriesScreenContentTest {
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() {
fun seriesScreenContent_focusesNextUpEpisode_whenNextUpExists() {
composeRule.setContent {
AppTheme {
TvSeriesScreenContent(
@@ -44,8 +44,7 @@ class SeriesScreenContentTest {
composeRule.onNodeWithText("Severance").assertIsDisplayed()
composeRule.onNodeWithText("Overview").assertIsDisplayed()
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed()
composeRule.onNodeWithTag(SeriesNextUpEpisodeCardTag).assertIsDisplayed()
.assertIsFocused()
composeRule.onNodeWithText("Season 1").assertIsDisplayed()
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
@@ -66,7 +65,6 @@ class SeriesScreenContentTest {
composeRule.waitForIdle()
composeRule.onNodeWithText("Overview").assertIsDisplayed()
composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed()
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag)
.assertIsDisplayed()
.assertIsFocused()
@@ -87,7 +85,7 @@ class SeriesScreenContentTest {
composeRule.waitForIdle()
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
composeRule.onNodeWithTag(SeriesNextUpEpisodeCardTag).assertIsFocused()
composeRule.onRoot().performKeyInput {
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.heightIn
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.unit.dp
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.ImageUrlBuilder
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
internal val MediaDetailHorizontalPadding = 48.dp
private val MediaDetailHeaderTopPadding = 104.dp
private val MediaDetailHeaderBottomPadding = 36.dp
private val MediaDetailHeaderTopPadding = 6.dp
private val MediaDetailHeaderBottomPadding = 4.dp
private const val MediaDetailBodyImageWidthFraction = 0.66f
internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
@@ -61,19 +57,13 @@ internal val TvMediaDetailBringIntoViewSpec: BringIntoViewSpec =
internal fun TvMediaDetailScaffold(
resetScrollKey: Any,
modifier: Modifier = Modifier,
bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> }
bodyContent: @Composable (Modifier) -> Unit = {}
) {
val scheme = MaterialTheme.colorScheme
val contentPadding = Modifier.padding(horizontal = MediaDetailHorizontalPadding)
val listState = rememberLazyListState()
LaunchedEffect(resetScrollKey) {
listState.scrollToItem(0)
}
CompositionLocalProvider(LocalBringIntoViewSpec provides TvMediaDetailBringIntoViewSpec) {
LazyColumn(
state = listState,
Column(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
@@ -87,7 +77,7 @@ internal fun TvMediaDetailScaffold(
internal fun TvMediaDetailBodyBox(
backgroundImageUrl: String,
modifier: Modifier = Modifier,
heightFraction: Float = 0.48f,
heightFraction: Float = 0.24f,
content: @Composable ColumnScope.() -> Unit
) {
val scheme = MaterialTheme.colorScheme
@@ -183,12 +173,11 @@ internal fun MediaDetailOverviewSection(
title = "Overview",
titleColor = scheme.onBackground,
bodyColor = scheme.onSurfaceVariant.copy(alpha = 0.85f),
titleFontSize = 22.sp,
bodyFontSize = 16.sp,
bodyLineHeight = 24.sp,
titleSpacing = 14.dp,
collapsedLines = 5,
collapseInitially = false
titleFontSize = 15.sp,
bodyFontSize = 11.sp,
bodyLineHeight = 14.sp,
titleSpacing = 2.dp,
collapsedLines = 1
)
}

View File

@@ -85,9 +85,11 @@ internal fun TvEpisodeScreenContent(
resetScrollKey = episode.id,
modifier = modifier
) {
item(key = "episode-hero") {
TvMediaDetailBodyBox(
backgroundImageUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
backgroundImageUrl = ImageUrlBuilder.finishImageUrl(
episode.imageUrlPrefix,
ArtworkKind.PRIMARY
),
modifier = it
) {
TvEpisodeHeroSection(
@@ -97,31 +99,24 @@ internal fun TvEpisodeScreenContent(
playFocusRequester = playFocusRequester,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(2.dp))
}
}
item(key = "episode-overview") {
Column(modifier = it.fillMaxWidth()) {
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(4.dp))
MediaDetailOverviewSection(
synopsis = episode.synopsis,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(20.dp))
Spacer(modifier = Modifier.height(8.dp))
}
}
item(key = "episode-playback") {
Column(modifier = it.fillMaxWidth()) {
MediaDetailPlaybackSection(
audioTrack = "ENG",
subtitles = "ENG",
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(20.dp))
Spacer(modifier = Modifier.height(8.dp))
}
}
if (episode.cast.isNotEmpty()) {
item(key = "episode-cast") {
Column(modifier = it.fillMaxWidth()) {
MediaDetailSectionTitle(text = "Cast")
Spacer(modifier = Modifier.height(14.dp))
@@ -129,6 +124,4 @@ internal fun TvEpisodeScreenContent(
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
}

View File

@@ -39,47 +39,47 @@ internal fun TvEpisodeHeroSection(
Column(
modifier = modifier
.fillMaxWidth()
.widthIn(max = 760.dp)
.widthIn(max = 560.dp)
) {
if (!seriesTitle.isNullOrBlank()) {
Text(
text = seriesTitle,
color = scheme.primary,
fontSize = 18.sp,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(10.dp))
Spacer(modifier = Modifier.height(2.dp))
}
Text(
text = episode.title,
color = scheme.onBackground,
fontSize = 42.sp,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
lineHeight = 48.sp,
maxLines = 2,
lineHeight = 28.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "Episode ${episode.index}",
color = mutedStrong,
fontSize = 18.sp,
fontSize = 10.sp,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(18.dp))
Spacer(modifier = Modifier.height(4.dp))
MediaMetadataFlowRow(
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
highlightedItem = episode.format
)
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(6.dp))
MediaResumeButton(
text = mediaPlayButtonText(episode.progress, episode.watched),
progress = mediaPlaybackProgress(episode.progress),
onClick = onPlay,
modifier = Modifier
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
.sizeIn(minWidth = 160.dp, maxWidth = 192.dp)
.focusRequester(playFocusRequester)
.testTag(EpisodePlayButtonTag),
focusedScale = 1.08f,
@@ -88,7 +88,10 @@ internal fun TvEpisodeHeroSection(
focusBorderColor = scheme.onBackground,
overlayBorderWidth = 2.dp,
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
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -11,14 +11,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.navigation.MovieDto
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
@@ -27,7 +24,9 @@ import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
@Composable
fun TvMovieScreen(
movie: MovieDto, viewModel: MovieScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier
movie: MovieDto,
viewModel: MovieScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
LaunchedEffect(movie.id) {
viewModel.selectMovie(movie.id)
@@ -63,10 +62,15 @@ internal fun TvMovieScreenContent(
resetScrollKey = movie.id,
modifier = modifier
) {
item(key = "movie-hero") {
TvMediaDetailBodyBox(
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
modifier = it
modifier = Modifier.fillMaxSize(),
heightFraction = 1f
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = MediaDetailHorizontalPadding)
) {
TvMovieHeroSection(
movie = movie,
@@ -74,37 +78,6 @@ internal fun TvMovieScreenContent(
playFocusRequester = playFocusRequester,
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.unit.dp
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.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
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"
@@ -33,33 +33,67 @@ internal fun TvMovieHeroSection(
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.85f)
Column(
modifier = modifier
.padding(top = 24.dp)
.fillMaxWidth()
.widthIn(max = 760.dp)
) {
Text(
text = movie.title,
color = scheme.onBackground,
fontSize = 42.sp,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
lineHeight = 48.sp,
maxLines = 2,
lineHeight = 28.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(18.dp))
Spacer(modifier = Modifier.height(4.dp))
MediaMetadataFlowRow(
items = listOf(movie.year, movie.rating, movie.runtime, 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(
text = mediaPlayButtonText(movie.progress, movie.watched),
progress = mediaPlaybackProgress(movie.progress),
onClick = onPlay,
modifier = Modifier
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
.sizeIn(minWidth = 160.dp, maxWidth = 192.dp)
.focusRequester(playFocusRequester)
.testTag(MoviePlayButtonTag),
focusedScale = 1.08f,
@@ -68,7 +102,10 @@ internal fun TvMovieHeroSection(
focusBorderColor = scheme.onBackground,
overlayBorderWidth = 2.dp,
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -17,15 +19,14 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
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.Series
import hu.bbara.purefin.navigation.SeriesDto
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
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.TvEpisodeCarousel
import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs
@@ -63,82 +64,62 @@ internal fun TvSeriesScreenContent(
onPlayEpisode: (UUID) -> Unit,
modifier: Modifier = Modifier,
) {
var selectedSeason by remember(series.id) { mutableStateOf(series.defaultSeason()) }
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() }
LaunchedEffect(series.id, nextUpEpisode?.id) {
if (nextUpEpisode != null) return@LaunchedEffect
withFrameNanos { }
if (nextUpEpisode != null) {
playFocusRequester.requestFocus()
} else {
firstContentFocusRequester.requestFocus()
}
}
TvMediaDetailScaffold(
resetScrollKey = series.id,
modifier = modifier
) {
item(key = "series-hero") {
TvMediaDetailBodyBox(
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
modifier = it
modifier = Modifier.fillMaxSize(),
heightFraction = 1f
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = MediaDetailHorizontalPadding)
) {
TvSeriesHeroSection(
series = series,
nextUpEpisode = nextUpEpisode,
onPlayEpisode = { onPlayEpisode(it.id) },
playFocusRequester = playFocusRequester,
firstContentFocusRequester = firstContentFocusRequester,
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") {
Spacer(modifier = Modifier.height(18.dp))
TvSeasonTabs(
seasons = series.seasons,
selectedSeason = selectedSeason,
firstItemFocusRequester = firstContentFocusRequester,
firstItemTestTag = SeriesFirstSeasonTabTag,
onSelect = { selectedSeason = it },
modifier = it
modifier = Modifier.fillMaxWidth()
)
}
item(key = "series-episodes") {
Spacer(modifier = Modifier.height(20.dp))
Spacer(modifier = Modifier.height(4.dp))
TvEpisodeCarousel(
episodes = selectedSeason.episodes,
modifier = it
onPlayEpisode = { onPlayEpisode(it.id) },
focusedEpisodeId = nextUpEpisode?.id,
modifier = Modifier.fillMaxWidth()
)
}
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) {
if (season.episodes.any { !it.watched }) {
return season

View File

@@ -1,13 +1,16 @@
package hu.bbara.purefin.ui.screen.series.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -30,16 +31,17 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
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.unit.dp
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.ImageUrlBuilder
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.ui.common.badge.WatchStateBadge
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.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.screen.home.components.TvHomeRowBringIntoViewSpec
import java.util.UUID
internal const val SeriesPlayButtonTag = "series-play-button"
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
internal const val SeriesNextUpEpisodeCardTag = "series-next-up-episode-card"
@Composable
internal fun TvSeriesMetaChips(series: Series) {
@@ -152,26 +151,56 @@ private fun TvSeasonTab(
}
}
@OptIn(ExperimentalFoundationApi::class)
@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 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 }
if (firstUnwatchedIndex != 0) {
listState.animateScrollToItem(firstUnwatchedIndex)
val targetIndex = focusedEpisodeIndex.takeIf { it >= 0 } ?: firstUnwatchedIndex
if (targetIndex != 0) {
listState.scrollToItem(targetIndex)
} else {
listState.scrollToItem(0)
}
if (focusedEpisodeIndex >= 0) {
withFrameNanos { }
focusedEpisodeFocusRequester.requestFocus()
}
}
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
LazyRow(
state = listState,
modifier = modifier,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(episodes, key = { episode -> episode.id }) { episode ->
TvEpisodeCard(episode = 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
internal fun TvSeriesHeroSection(
series: Series,
nextUpEpisode: Episode?,
onPlayEpisode: (Episode) -> Unit,
playFocusRequester: FocusRequester,
firstContentFocusRequester: FocusRequester,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
@@ -190,81 +215,45 @@ internal fun TvSeriesHeroSection(
Column(
modifier = modifier
.padding(top = 24.dp)
.fillMaxWidth()
.widthIn(max = 760.dp)
) {
Text(
text = series.name,
color = scheme.onBackground,
fontSize = 42.sp,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
lineHeight = 48.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(18.dp))
TvSeriesMetaChips(series = series)
Spacer(modifier = Modifier.height(24.dp))
if (nextUpEpisode != null) {
Text(
text = nextUpEpisode.heroStatusText(),
color = scheme.primary,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
lineHeight = 28.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
TvSeriesMetaChips(series = series)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = nextUpEpisode.title,
text = "Overview",
color = scheme.onBackground,
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(6.dp))
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "Episode ${nextUpEpisode.index}${nextUpEpisode.runtime}",
text = series.synopsis,
color = mutedStrong,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
fontSize = 12.sp,
lineHeight = 16.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(0.4f)
)
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
private fun TvEpisodeCard(
viewModel: SeriesViewModel = hiltViewModel(),
episode: Episode
episode: Episode,
onPlayEpisode: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
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")
Column(
modifier = Modifier
modifier = modifier
.width(260.dp)
.graphicsLayer { scaleX = scale; scaleY = scale }
.onFocusChanged { isFocused = it.isFocused }
.clickable { viewModel.onSelectEpisode(
seriesId = episode.seriesId,
seasonId = episode.seasonId,
episodeId = episode.id
) },
.clickable { onPlayEpisode() },
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
@@ -375,7 +360,3 @@ internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
// 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.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
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.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -50,7 +52,10 @@ fun MediaResumeButton(
focusBorderColor: Color = Color.Transparent,
overlayBorderWidth: Dp = 0.dp,
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 primaryColor = scheme.primary
@@ -73,7 +78,7 @@ fun MediaResumeButton(
BoxWithConstraints(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.height(52.dp)
.height(height)
.background(haloColor, shape)
.border(
width = if (isFocused) focusBorderWidth else 0.dp,
@@ -91,7 +96,12 @@ fun MediaResumeButton(
.background(onPrimaryColor),
contentAlignment = Alignment.Center
) {
ResumeButtonContent(text = text, color = primaryColor)
ResumeButtonContent(
text = text,
color = primaryColor,
textSize = textSize,
iconSize = iconSize
)
}
Box(
@@ -111,7 +121,12 @@ fun MediaResumeButton(
.background(primaryColor),
contentAlignment = Alignment.Center
) {
ResumeButtonContent(text = text, color = onPrimaryColor)
ResumeButtonContent(
text = text,
color = onPrimaryColor,
textSize = textSize,
iconSize = iconSize
)
}
if (isFocused && overlayBorderWidth > 0.dp) {
@@ -125,13 +140,23 @@ fun MediaResumeButton(
}
@Composable
private fun ResumeButtonContent(text: String, color: Color) {
private fun ResumeButtonContent(
text: String,
color: Color,
textSize: TextUnit,
iconSize: Dp
) {
Row(
verticalAlignment = Alignment.CenterVertically,
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))
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) {
is MovieUiModel -> onMovieSelected(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.Movie
import hu.bbara.purefin.model.Series
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -105,7 +106,14 @@ class InMemoryMediaRepository @Inject constructor(
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
scope.launch {
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] }
}

View File

@@ -6,6 +6,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.data.PlaybackMethod
import hu.bbara.purefin.data.PlaybackReportContext
import hu.bbara.purefin.data.UserSessionRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@@ -85,26 +86,31 @@ class JellyfinApiClient @Inject constructor(
}
suspend fun configureFromSession(): Boolean = withContext(Dispatchers.IO) {
logApiFailure("configureFromSession") {
ensureConfigured()
}
}
suspend fun authenticate(
url: String,
username: String,
password: String,
): AuthenticationResult? = withContext(Dispatchers.IO) {
logApiFailure("authenticate") {
val trimmedUrl = url.trim()
if (trimmedUrl.isBlank()) {
return@withContext null
return@logApiFailure null
}
api.update(baseUrl = trimmedUrl)
api.userApi.authenticateUserByName(username = username, password = password).content
}
}
suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
logApiFailure("getLibraries") {
if (!ensureConfigured()) {
return@withContext emptyList()
return@logApiFailure emptyList()
}
val response = api.userViewsApi.getUserViews(
userId = getUserId(),
@@ -114,10 +120,12 @@ class JellyfinApiClient @Inject constructor(
Log.d("getLibraries", response.content.toString())
response.content.items
}
}
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
logApiFailure("getLibraryContent($libraryId)") {
if (!ensureConfigured()) {
return@withContext emptyList()
return@logApiFailure emptyList()
}
val getItemsRequest = GetItemsRequest(
userId = getUserId(),
@@ -132,12 +140,14 @@ class JellyfinApiClient @Inject constructor(
Log.d("getLibraryContent", response.content.toString())
response.content.items
}
}
suspend fun getSuggestions(): List<BaseItemDto> = withContext(Dispatchers.IO) {
logApiFailure("getSuggestions") {
if (!ensureConfigured()) {
return@withContext emptyList()
return@logApiFailure emptyList()
}
val userId = getUserId() ?: return@withContext emptyList()
val userId = getUserId() ?: return@logApiFailure emptyList()
val response = api.suggestionsApi.getSuggestions(
userId = userId,
mediaType = listOf(MediaType.VIDEO),
@@ -148,12 +158,14 @@ class JellyfinApiClient @Inject constructor(
Log.d("getSuggestions", response.content.toString())
response.content.items
}
}
suspend fun getContinueWatching(): List<BaseItemDto> = withContext(Dispatchers.IO) {
logApiFailure("getContinueWatching") {
if (!ensureConfigured()) {
return@withContext emptyList()
return@logApiFailure emptyList()
}
val userId = getUserId() ?: return@withContext emptyList()
val userId = getUserId() ?: return@logApiFailure emptyList()
val getResumeItemsRequest = GetResumeItemsRequest(
userId = userId,
fields = itemFields,
@@ -165,8 +177,10 @@ class JellyfinApiClient @Inject constructor(
Log.d("getContinueWatching", response.content.toString())
response.content.items
}
}
suspend fun getNextUpEpisodes(): List<BaseItemDto> = withContext(Dispatchers.IO) {
logApiFailure("getNextUpEpisodes") {
if (!ensureConfigured()) {
throw IllegalStateException("Not configured")
}
@@ -179,10 +193,12 @@ class JellyfinApiClient @Inject constructor(
Log.d("getNextUpEpisodes", result.content.toString())
result.content.items
}
}
suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
logApiFailure("getLatestFromLibrary($libraryId)") {
if (!ensureConfigured()) {
return@withContext emptyList()
return@logApiFailure emptyList()
}
val response = api.userLibraryApi.getLatestMedia(
userId = getUserId(),
@@ -194,19 +210,23 @@ class JellyfinApiClient @Inject constructor(
Log.d("getLatestFromLibrary", response.content.toString())
response.content
}
}
suspend fun getItemInfo(mediaId: UUID): BaseItemDto? = withContext(Dispatchers.IO) {
logApiFailure("getItemInfo($mediaId)") {
if (!ensureConfigured()) {
return@withContext null
return@logApiFailure null
}
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) {
logApiFailure("getSeasons($seriesId)") {
if (!ensureConfigured()) {
return@withContext emptyList()
return@logApiFailure emptyList()
}
val result = api.tvShowsApi.getSeasons(
userId = getUserId(),
@@ -217,10 +237,12 @@ class JellyfinApiClient @Inject constructor(
Log.d("getSeasons", result.content.toString())
result.content.items
}
}
suspend fun getEpisodesInSeason(seriesId: UUID, seasonId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
logApiFailure("getEpisodesInSeason(series=$seriesId, season=$seasonId)") {
if (!ensureConfigured()) {
return@withContext emptyList()
return@logApiFailure emptyList()
}
val result = api.tvShowsApi.getEpisodes(
userId = getUserId(),
@@ -232,13 +254,15 @@ class JellyfinApiClient @Inject constructor(
Log.d("getEpisodesInSeason", result.content.toString())
result.content.items
}
}
suspend fun getNextEpisodes(episodeId: UUID, count: Int): List<BaseItemDto> = withContext(Dispatchers.IO) {
logApiFailure("getNextEpisodes($episodeId, count=$count)") {
if (!ensureConfigured()) {
return@withContext emptyList()
return@logApiFailure emptyList()
}
val episodeInfo = getItemInfo(episodeId) ?: return@withContext emptyList()
val seriesId = episodeInfo.seriesId ?: return@withContext emptyList()
val episodeInfo = getItemInfo(episodeId) ?: return@logApiFailure emptyList()
val seriesId = episodeInfo.seriesId ?: return@logApiFailure emptyList()
val nextUpEpisodesResult = api.tvShowsApi.getEpisodes(
userId = getUserId(),
seriesId = seriesId,
@@ -250,10 +274,12 @@ class JellyfinApiClient @Inject constructor(
Log.d("getNextEpisodes", nextUpEpisodes.toString())
nextUpEpisodes
}
}
suspend fun getGenres(id: UUID? = null) : List<BaseItemDto> = withContext(Dispatchers.IO) {
logApiFailure("getGenres($id)") {
if (!ensureConfigured()) {
return@withContext emptyList()
return@logApiFailure emptyList()
}
val result = api.genresApi.getGenres(
userId = getUserId(),
@@ -262,10 +288,12 @@ class JellyfinApiClient @Inject constructor(
Log.d("getGenres", result.toString())
result.content.items
}
}
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
logApiFailure("getMediaSources($mediaId)") {
if (!ensureConfigured()) {
return@withContext emptyList()
return@logApiFailure emptyList()
}
val result = api.mediaInfoApi.getPostedPlaybackInfo(
mediaId,
@@ -278,10 +306,12 @@ class JellyfinApiClient @Inject constructor(
Log.d("getMediaSources", result.toString())
result.content.mediaSources
}
}
suspend fun getMediaSegments(mediaId: UUID) : List<MediaSegmentDto> = withContext(Dispatchers.IO) {
logApiFailure("getMediaSegments($mediaId)") {
if (!ensureConfigured()) {
return@withContext emptyList()
return@logApiFailure emptyList()
}
val result = api.mediaSegmentsApi.getItemSegments(
itemId = mediaId,
@@ -290,13 +320,15 @@ class JellyfinApiClient @Inject constructor(
Log.d("getMediaSegments", result.toString())
result.content.items
}
}
suspend fun getPlaybackInfo(
mediaId: UUID,
deviceProfile: DeviceProfile,
): PlaybackInfoResponse? = withContext(Dispatchers.IO) {
logApiFailure("getPlaybackInfo($mediaId)") {
if (!ensureConfigured()) {
return@withContext null
return@logApiFailure null
}
api.mediaInfoApi.getPostedPlaybackInfo(
mediaId,
@@ -312,6 +344,7 @@ class JellyfinApiClient @Inject constructor(
),
).content
}
}
fun getVideoStreamUrl(
itemId: UUID,
@@ -320,7 +353,8 @@ class JellyfinApiClient @Inject constructor(
tag: String? = null,
playSessionId: String? = null,
liveStreamId: String? = null,
): String = api.videosApi.getVideoStreamUrl(
): String = try {
api.videosApi.getVideoStreamUrl(
itemId = itemId,
container = container,
mediaSourceId = mediaSourceId,
@@ -329,20 +363,27 @@ class JellyfinApiClient @Inject constructor(
playSessionId = playSessionId,
liveStreamId = liveStreamId,
)
} catch (error: Exception) {
Log.e(TAG, "getVideoStreamUrl($itemId) failed", error)
throw error
}
suspend fun getPublicSystemInfoVersion(): String? = withContext(Dispatchers.IO) {
logApiFailure("getPublicSystemInfoVersion") {
if (!ensureConfigured()) {
return@withContext null
return@logApiFailure null
}
SystemApi(api).getPublicSystemInfo().content.version
}
}
suspend fun reportPlaybackStart(
itemId: UUID,
positionTicks: Long = 0L,
reportContext: PlaybackReportContext,
) = withContext(Dispatchers.IO) {
if (!ensureConfigured()) return@withContext
logApiFailure("reportPlaybackStart($itemId)") {
if (!ensureConfigured()) return@logApiFailure
api.playStateApi.reportPlaybackStart(
PlaybackStartInfo(
itemId = itemId,
@@ -361,6 +402,7 @@ class JellyfinApiClient @Inject constructor(
),
)
}
}
suspend fun reportPlaybackProgress(
itemId: UUID,
@@ -368,7 +410,8 @@ class JellyfinApiClient @Inject constructor(
isPaused: Boolean,
reportContext: PlaybackReportContext,
) = withContext(Dispatchers.IO) {
if (!ensureConfigured()) return@withContext
logApiFailure("reportPlaybackProgress($itemId)") {
if (!ensureConfigured()) return@logApiFailure
api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
itemId = itemId,
@@ -387,13 +430,15 @@ class JellyfinApiClient @Inject constructor(
),
)
}
}
suspend fun reportPlaybackStopped(
itemId: UUID,
positionTicks: Long,
reportContext: PlaybackReportContext,
) = withContext(Dispatchers.IO) {
if (!ensureConfigured()) return@withContext
logApiFailure("reportPlaybackStopped($itemId)") {
if (!ensureConfigured()) return@logApiFailure
api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = itemId,
@@ -405,10 +450,26 @@ class JellyfinApiClient @Inject constructor(
),
)
}
}
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) {
PlaybackMethod.DIRECT_PLAY -> PlayMethod.DIRECT_PLAY
PlaybackMethod.DIRECT_STREAM -> PlayMethod.DIRECT_STREAM
PlaybackMethod.TRANSCODE -> PlayMethod.TRANSCODE
}
companion object {
private const val TAG = "JellyfinApiClient"
}
}