diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/TvSeriesScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/TvSeriesScreen.kt index e85ecc31..f384e877 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/TvSeriesScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/TvSeriesScreen.kt @@ -13,11 +13,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text as TvText import hu.bbara.purefin.core.feature.content.series.SeriesViewModel import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Season @@ -49,7 +51,7 @@ fun TvSeriesScreen( val series = viewModel.series.collectAsStateWithLifecycle() val seriesData = series.value - if (seriesData != null && seriesData.seasons.isNotEmpty()) { + if (seriesData != null) { TvSeriesScreenContent( series = seriesData, onPlayEpisode = viewModel::onPlayEpisode, @@ -74,28 +76,27 @@ internal fun TvSeriesScreenContent( ) { val defaultSeason = series.defaultSeason(focusedSeasonId) var selectedSeasonId by remember(series.id, focusedSeasonId) { - mutableStateOf(defaultSeason.id) + mutableStateOf(defaultSeason?.id) } val selectedSeason = series.seasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason - val initialFocusSeasonId = remember(series.id, focusedSeasonId) { defaultSeason.id } - val initialFocusSeason = series.seasons.firstOrNull { it.id == initialFocusSeasonId } ?: defaultSeason - val initialFocusedEpisodeId = initialFocusSeason.focusTargetEpisodeId(focusedEpisodeId) + val initialFocusSeasonId = defaultSeason?.id + val initialFocusSeason = initialFocusSeasonId?.let { seasonId -> + series.seasons.firstOrNull { it.id == seasonId } + } ?: defaultSeason + val initialFocusedEpisodeId = initialFocusSeason?.focusTargetEpisodeId(focusedEpisodeId) val seasonTabFocusRequester = remember { FocusRequester() } var requestedInitialEpisodeFocus by remember(series.id, focusedSeasonId, focusedEpisodeId) { mutableStateOf(false) } + var requestedInitialSeasonFocus by remember(series.id, focusedSeasonId, focusedEpisodeId) { + mutableStateOf(false) + } val waitingForInitialEpisodes = initialFocusedEpisodeId == null && - initialFocusSeason.episodes.isEmpty() && + initialFocusSeason?.episodes?.isEmpty() == true && initialFocusSeason.episodeCount > 0 - LaunchedEffect(series.id, selectedSeason.id) { - onLoadSeasonEpisodes(series.id, selectedSeason.id) - } - - LaunchedEffect(series.id, initialFocusedEpisodeId, waitingForInitialEpisodes) { - if (initialFocusedEpisodeId != null || waitingForInitialEpisodes) return@LaunchedEffect - withFrameNanos { } - seasonTabFocusRequester.requestFocus() + LaunchedEffect(series.id, selectedSeason?.id) { + selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) } } TvMediaDetailScaffold( @@ -117,35 +118,48 @@ internal fun TvSeriesScreenContent( modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(16.dp)) - TvSeasonTabs( - seasons = series.seasons, - selectedSeason = selectedSeason, - selectedItemFocusRequester = seasonTabFocusRequester, - firstItemTestTag = SeriesFirstSeasonTabTag, - onSelect = { selectedSeasonId = it.id }, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(16.dp)) - TvEpisodeCarousel( - episodes = selectedSeason.episodes, - onPlayEpisode = { onPlayEpisode(it.id) }, - focusedEpisodeId = initialFocusedEpisodeId, - requestFocus = selectedSeason.id == initialFocusSeasonId && !requestedInitialEpisodeFocus, - onFocusRequested = { requestedInitialEpisodeFocus = true }, - upFocusRequester = seasonTabFocusRequester, - modifier = Modifier.fillMaxWidth() - ) + if (selectedSeason != null) { + TvSeasonTabs( + seasons = series.seasons, + selectedSeason = selectedSeason, + selectedItemFocusRequester = seasonTabFocusRequester, + firstItemTestTag = SeriesFirstSeasonTabTag, + requestSelectedItemFocus = initialFocusedEpisodeId == null && + !waitingForInitialEpisodes && + !requestedInitialSeasonFocus, + onSelectedItemFocusRequested = { requestedInitialSeasonFocus = true }, + onSelect = { selectedSeasonId = it.id }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + TvEpisodeCarousel( + episodes = selectedSeason.episodes, + onPlayEpisode = { onPlayEpisode(it.id) }, + focusedEpisodeId = initialFocusedEpisodeId, + requestFocus = selectedSeason.id == initialFocusSeasonId && !requestedInitialEpisodeFocus, + onFocusRequested = { requestedInitialEpisodeFocus = true }, + upFocusRequester = seasonTabFocusRequester, + modifier = Modifier.fillMaxWidth() + ) + } else { + TvText( + text = "Loading seasons...", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp, + modifier = Modifier.padding(vertical = 8.dp) + ) + } } } } } -private fun Series.defaultSeason(focusedSeasonId: UUID?): Season { +private fun Series.defaultSeason(focusedSeasonId: UUID?): Season? { if (focusedSeasonId != null) { seasons.firstOrNull { it.id == focusedSeasonId }?.let { return it } } - return seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: seasons.first() + return seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: seasons.firstOrNull() } private fun Season.nextUpEpisode(): Episode? { diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/components/TvSeriesComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/components/TvSeriesComponents.kt index 6464567f..e8e02e3e 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/components/TvSeriesComponents.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/components/TvSeriesComponents.kt @@ -19,9 +19,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -45,6 +45,7 @@ import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag @@ -52,9 +53,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.tv.material3.Tab -import androidx.tv.material3.TabDefaults -import androidx.tv.material3.TabRow import hu.bbara.purefin.core.image.ArtworkKind import hu.bbara.purefin.core.image.ImageUrlBuilder import hu.bbara.purefin.model.CastMember @@ -80,18 +78,32 @@ internal fun TvSeriesMetaChips(series: Series) { } @Composable +@OptIn(ExperimentalFoundationApi::class) internal fun TvSeasonTabs( seasons: List, selectedSeason: Season?, modifier: Modifier = Modifier, selectedItemFocusRequester: FocusRequester? = null, firstItemTestTag: String? = null, + requestSelectedItemFocus: Boolean = false, + onSelectedItemFocusRequested: () -> Unit = {}, onSelect: (Season) -> Unit ) { val selectedSeasonIndex = seasons.indexOf(selectedSeason).coerceAtLeast(0) + val listState = rememberLazyListState() - TabRow( - selectedTabIndex = selectedSeasonIndex, + LaunchedEffect(selectedSeasonIndex, requestSelectedItemFocus, seasons.size) { + if (seasons.isEmpty()) return@LaunchedEffect + listState.scrollToItem(selectedSeasonIndex) + if (requestSelectedItemFocus && selectedItemFocusRequester != null) { + withFrameNanos { } + selectedItemFocusRequester.requestFocus() + onSelectedItemFocusRequested() + } + } + + LazyRow( + state = listState, modifier = modifier .then( if (selectedItemFocusRequester != null) { @@ -101,16 +113,16 @@ internal fun TvSeasonTabs( } ) .focusGroup() - .fillMaxWidth() - .wrapContentWidth(Alignment.Start), - contentColor = MaterialTheme.colorScheme.onSurface + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(0.dp), + verticalAlignment = Alignment.CenterVertically, ) { - seasons.forEachIndexed { index, season -> - Tab( - selected = index == selectedSeasonIndex, - onFocus = { onSelect(season) }, + itemsIndexed(seasons, key = { _, season -> season.id }) { index, season -> + TvSeasonTab( + name = season.name, + isSelected = index == selectedSeasonIndex, + onFocused = { onSelect(season) }, onClick = { onSelect(season) }, - colors = TabDefaults.underlinedIndicatorTabColors(), modifier = Modifier .then( if (index == selectedSeasonIndex && selectedItemFocusRequester != null) { @@ -126,24 +138,48 @@ internal fun TvSeasonTabs( Modifier } ) - ) { - TvText( - text = season.name, - fontSize = 13.sp, - fontWeight = if (index == selectedSeasonIndex) { - FontWeight.Bold - } else { - FontWeight.Medium - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) - } + ) } } } +@Composable +private fun TvSeasonTab( + name: String, + isSelected: Boolean, + onFocused: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val scheme = MaterialTheme.colorScheme + val color = if (isSelected) scheme.primary else scheme.onSurface + val indicatorColor = if (isSelected) scheme.primary else Color.Transparent + + Column( + modifier = modifier + .onFocusChanged { state -> + if (state.isFocused) onFocused() + } + .clickable(onClick = onClick) + ) { + TvText( + text = name, + color = color, + fontSize = 13.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .background(indicatorColor) + ) + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable internal fun TvEpisodeCarousel( diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt index f668b565..7396c8fa 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt @@ -87,7 +87,7 @@ fun SeriesScreen( } val seriesData = seriesState.value - if (seriesData != null && seriesData.seasons.isNotEmpty()) { + if (seriesData != null) { LaunchedEffect(seriesData) { viewModel.observeSeriesDownloadState(seriesData) } @@ -170,22 +170,22 @@ private fun SeriesScreenInternal( ) { val scheme = MaterialTheme.colorScheme - fun getDefaultSeason(): Season { - return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.first() + fun getDefaultSeason(): Season? { + return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.firstOrNull() } - var selectedSeasonId by remember(series.id) { mutableStateOf(getDefaultSeason().id) } + var selectedSeasonId by remember(series.id) { mutableStateOf(getDefaultSeason()?.id) } val selectedSeason = series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason() - val nextUpEpisode = selectedSeason.episodes.firstOrNull { !it.watched } - ?: selectedSeason.episodes.firstOrNull() + val nextUpEpisode = selectedSeason?.episodes?.firstOrNull { !it.watched } + ?: selectedSeason?.episodes?.firstOrNull() - LaunchedEffect(series.id, selectedSeason.id) { - onLoadSeasonEpisodes(series.id, selectedSeason.id) + LaunchedEffect(series.id, selectedSeason?.id) { + selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) } } - LaunchedEffect(selectedSeason.id, selectedSeason.episodes) { - onObserveSeasonDownloadState(selectedSeason.episodes) + LaunchedEffect(selectedSeason?.id, selectedSeason?.episodes) { + selectedSeason?.let { onObserveSeasonDownloadState(it.episodes) } } MediaDetailScaffold( @@ -198,17 +198,19 @@ private fun SeriesScreenInternal( SeriesHeroContent(series = series) } ) { - SeriesActionButtons( - nextUpEpisode = nextUpEpisode, - selectedSeason = selectedSeason, - seriesDownloadState = seriesDownloadState, - seasonDownloadState = seasonDownloadState, - isSmartDownloadEnabled = isSmartDownloadEnabled, - offline = offline, - onDownloadOptionSelected = { option -> - onDownloadOptionSelected(option, selectedSeason) - } - ) + if (selectedSeason != null) { + SeriesActionButtons( + nextUpEpisode = nextUpEpisode, + selectedSeason = selectedSeason, + seriesDownloadState = seriesDownloadState, + seasonDownloadState = seasonDownloadState, + isSmartDownloadEnabled = isSmartDownloadEnabled, + offline = offline, + onDownloadOptionSelected = { option -> + onDownloadOptionSelected(option, selectedSeason) + } + ) + } MediaSynopsis( synopsis = series.synopsis, bodyColor = scheme.onSurface, @@ -216,14 +218,22 @@ private fun SeriesScreenInternal( bodyLineHeight = null, titleSpacing = 8.dp ) - SeasonTabs( - seasons = series.seasons, - selectedSeason = selectedSeason, - onSelect = { selectedSeasonId = it.id } - ) - EpisodeCarousel( - episodes = selectedSeason.episodes, - ) + if (selectedSeason != null) { + SeasonTabs( + seasons = series.seasons, + selectedSeason = selectedSeason, + onSelect = { selectedSeasonId = it.id } + ) + EpisodeCarousel( + episodes = selectedSeason.episodes, + ) + } else { + Text( + text = "Loading seasons...", + color = scheme.onSurfaceVariant, + fontSize = 13.sp + ) + } if (series.cast.isNotEmpty()) { Text( text = "Cast", diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt index 7e48a988..76235792 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt @@ -4,7 +4,6 @@ import android.content.Intent import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -20,8 +19,8 @@ import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add @@ -333,14 +332,23 @@ internal fun SeasonTabs( modifier: Modifier = Modifier, onSelect: (Season) -> Unit ) { - Row( + val selectedSeasonIndex = seasons.indexOf(selectedSeason).coerceAtLeast(0) + val listState = rememberLazyListState() + + LaunchedEffect(selectedSeasonIndex, seasons.size) { + if (seasons.isNotEmpty()) { + listState.scrollToItem(selectedSeasonIndex) + } + } + + LazyRow( + state = listState, modifier = modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), + .fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(20.dp), verticalAlignment = Alignment.CenterVertically ) { - seasons.forEachIndexed { index, season -> + itemsIndexed(seasons, key = { _, season -> season.id }) { index, season -> SeasonTab( name = season.name, isSelected = season == selectedSeason, diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt index 5fc68a43..4cd48160 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt @@ -332,7 +332,7 @@ class JellyfinApiClient @Inject constructor( userId = getUserId(), seriesId = seriesId, fields = defaultItemFields, - enableUserData = true, + enableUserData = false, ) result.content.items }