feat(series): improve season and episode loading handling

This commit is contained in:
2026-05-25 20:40:10 +02:00
parent 3cd706d449
commit b0ed32ef7c
5 changed files with 167 additions and 99 deletions

View File

@@ -13,11 +13,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.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.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel 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.core.feature.content.series.SeriesViewModel
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Season import hu.bbara.purefin.model.Season
@@ -49,7 +51,7 @@ fun TvSeriesScreen(
val series = viewModel.series.collectAsStateWithLifecycle() val series = viewModel.series.collectAsStateWithLifecycle()
val seriesData = series.value val seriesData = series.value
if (seriesData != null && seriesData.seasons.isNotEmpty()) { if (seriesData != null) {
TvSeriesScreenContent( TvSeriesScreenContent(
series = seriesData, series = seriesData,
onPlayEpisode = viewModel::onPlayEpisode, onPlayEpisode = viewModel::onPlayEpisode,
@@ -74,28 +76,27 @@ internal fun TvSeriesScreenContent(
) { ) {
val defaultSeason = series.defaultSeason(focusedSeasonId) val defaultSeason = series.defaultSeason(focusedSeasonId)
var selectedSeasonId by remember(series.id, focusedSeasonId) { var selectedSeasonId by remember(series.id, focusedSeasonId) {
mutableStateOf(defaultSeason.id) mutableStateOf(defaultSeason?.id)
} }
val selectedSeason = series.seasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason val selectedSeason = series.seasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason
val initialFocusSeasonId = remember(series.id, focusedSeasonId) { defaultSeason.id } val initialFocusSeasonId = defaultSeason?.id
val initialFocusSeason = series.seasons.firstOrNull { it.id == initialFocusSeasonId } ?: defaultSeason val initialFocusSeason = initialFocusSeasonId?.let { seasonId ->
val initialFocusedEpisodeId = initialFocusSeason.focusTargetEpisodeId(focusedEpisodeId) series.seasons.firstOrNull { it.id == seasonId }
} ?: defaultSeason
val initialFocusedEpisodeId = initialFocusSeason?.focusTargetEpisodeId(focusedEpisodeId)
val seasonTabFocusRequester = remember { FocusRequester() } val seasonTabFocusRequester = remember { FocusRequester() }
var requestedInitialEpisodeFocus by remember(series.id, focusedSeasonId, focusedEpisodeId) { var requestedInitialEpisodeFocus by remember(series.id, focusedSeasonId, focusedEpisodeId) {
mutableStateOf(false) mutableStateOf(false)
} }
var requestedInitialSeasonFocus by remember(series.id, focusedSeasonId, focusedEpisodeId) {
mutableStateOf(false)
}
val waitingForInitialEpisodes = initialFocusedEpisodeId == null && val waitingForInitialEpisodes = initialFocusedEpisodeId == null &&
initialFocusSeason.episodes.isEmpty() && initialFocusSeason?.episodes?.isEmpty() == true &&
initialFocusSeason.episodeCount > 0 initialFocusSeason.episodeCount > 0
LaunchedEffect(series.id, selectedSeason.id) { LaunchedEffect(series.id, selectedSeason?.id) {
onLoadSeasonEpisodes(series.id, selectedSeason.id) selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) }
}
LaunchedEffect(series.id, initialFocusedEpisodeId, waitingForInitialEpisodes) {
if (initialFocusedEpisodeId != null || waitingForInitialEpisodes) return@LaunchedEffect
withFrameNanos { }
seasonTabFocusRequester.requestFocus()
} }
TvMediaDetailScaffold( TvMediaDetailScaffold(
@@ -117,35 +118,48 @@ internal fun TvSeriesScreenContent(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
TvSeasonTabs( if (selectedSeason != null) {
seasons = series.seasons, TvSeasonTabs(
selectedSeason = selectedSeason, seasons = series.seasons,
selectedItemFocusRequester = seasonTabFocusRequester, selectedSeason = selectedSeason,
firstItemTestTag = SeriesFirstSeasonTabTag, selectedItemFocusRequester = seasonTabFocusRequester,
onSelect = { selectedSeasonId = it.id }, firstItemTestTag = SeriesFirstSeasonTabTag,
modifier = Modifier.fillMaxWidth() requestSelectedItemFocus = initialFocusedEpisodeId == null &&
) !waitingForInitialEpisodes &&
Spacer(modifier = Modifier.height(16.dp)) !requestedInitialSeasonFocus,
TvEpisodeCarousel( onSelectedItemFocusRequested = { requestedInitialSeasonFocus = true },
episodes = selectedSeason.episodes, onSelect = { selectedSeasonId = it.id },
onPlayEpisode = { onPlayEpisode(it.id) }, modifier = Modifier.fillMaxWidth()
focusedEpisodeId = initialFocusedEpisodeId, )
requestFocus = selectedSeason.id == initialFocusSeasonId && !requestedInitialEpisodeFocus, Spacer(modifier = Modifier.height(16.dp))
onFocusRequested = { requestedInitialEpisodeFocus = true }, TvEpisodeCarousel(
upFocusRequester = seasonTabFocusRequester, episodes = selectedSeason.episodes,
modifier = Modifier.fillMaxWidth() 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) { if (focusedSeasonId != null) {
seasons.firstOrNull { it.id == focusedSeasonId }?.let { return it } 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? { private fun Season.nextUpEpisode(): Episode? {

View File

@@ -19,9 +19,9 @@ 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.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag 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.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.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.ArtworkKind
import hu.bbara.purefin.core.image.ImageUrlBuilder import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.model.CastMember import hu.bbara.purefin.model.CastMember
@@ -80,18 +78,32 @@ internal fun TvSeriesMetaChips(series: Series) {
} }
@Composable @Composable
@OptIn(ExperimentalFoundationApi::class)
internal fun TvSeasonTabs( internal fun TvSeasonTabs(
seasons: List<Season>, seasons: List<Season>,
selectedSeason: Season?, selectedSeason: Season?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
selectedItemFocusRequester: FocusRequester? = null, selectedItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null, firstItemTestTag: String? = null,
requestSelectedItemFocus: Boolean = false,
onSelectedItemFocusRequested: () -> Unit = {},
onSelect: (Season) -> Unit onSelect: (Season) -> Unit
) { ) {
val selectedSeasonIndex = seasons.indexOf(selectedSeason).coerceAtLeast(0) val selectedSeasonIndex = seasons.indexOf(selectedSeason).coerceAtLeast(0)
val listState = rememberLazyListState()
TabRow( LaunchedEffect(selectedSeasonIndex, requestSelectedItemFocus, seasons.size) {
selectedTabIndex = selectedSeasonIndex, if (seasons.isEmpty()) return@LaunchedEffect
listState.scrollToItem(selectedSeasonIndex)
if (requestSelectedItemFocus && selectedItemFocusRequester != null) {
withFrameNanos { }
selectedItemFocusRequester.requestFocus()
onSelectedItemFocusRequested()
}
}
LazyRow(
state = listState,
modifier = modifier modifier = modifier
.then( .then(
if (selectedItemFocusRequester != null) { if (selectedItemFocusRequester != null) {
@@ -101,16 +113,16 @@ internal fun TvSeasonTabs(
} }
) )
.focusGroup() .focusGroup()
.fillMaxWidth() .fillMaxWidth(),
.wrapContentWidth(Alignment.Start), horizontalArrangement = Arrangement.spacedBy(0.dp),
contentColor = MaterialTheme.colorScheme.onSurface verticalAlignment = Alignment.CenterVertically,
) { ) {
seasons.forEachIndexed { index, season -> itemsIndexed(seasons, key = { _, season -> season.id }) { index, season ->
Tab( TvSeasonTab(
selected = index == selectedSeasonIndex, name = season.name,
onFocus = { onSelect(season) }, isSelected = index == selectedSeasonIndex,
onFocused = { onSelect(season) },
onClick = { onSelect(season) }, onClick = { onSelect(season) },
colors = TabDefaults.underlinedIndicatorTabColors(),
modifier = Modifier modifier = Modifier
.then( .then(
if (index == selectedSeasonIndex && selectedItemFocusRequester != null) { if (index == selectedSeasonIndex && selectedItemFocusRequester != null) {
@@ -126,24 +138,48 @@ internal fun TvSeasonTabs(
Modifier 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
internal fun TvEpisodeCarousel( internal fun TvEpisodeCarousel(

View File

@@ -87,7 +87,7 @@ fun SeriesScreen(
} }
val seriesData = seriesState.value val seriesData = seriesState.value
if (seriesData != null && seriesData.seasons.isNotEmpty()) { if (seriesData != null) {
LaunchedEffect(seriesData) { LaunchedEffect(seriesData) {
viewModel.observeSeriesDownloadState(seriesData) viewModel.observeSeriesDownloadState(seriesData)
} }
@@ -170,22 +170,22 @@ private fun SeriesScreenInternal(
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
fun getDefaultSeason(): Season { fun getDefaultSeason(): Season? {
return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.first() 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 = val selectedSeason =
series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason() series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
val nextUpEpisode = selectedSeason.episodes.firstOrNull { !it.watched } val nextUpEpisode = selectedSeason?.episodes?.firstOrNull { !it.watched }
?: selectedSeason.episodes.firstOrNull() ?: selectedSeason?.episodes?.firstOrNull()
LaunchedEffect(series.id, selectedSeason.id) { LaunchedEffect(series.id, selectedSeason?.id) {
onLoadSeasonEpisodes(series.id, selectedSeason.id) selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) }
} }
LaunchedEffect(selectedSeason.id, selectedSeason.episodes) { LaunchedEffect(selectedSeason?.id, selectedSeason?.episodes) {
onObserveSeasonDownloadState(selectedSeason.episodes) selectedSeason?.let { onObserveSeasonDownloadState(it.episodes) }
} }
MediaDetailScaffold( MediaDetailScaffold(
@@ -198,17 +198,19 @@ private fun SeriesScreenInternal(
SeriesHeroContent(series = series) SeriesHeroContent(series = series)
} }
) { ) {
SeriesActionButtons( if (selectedSeason != null) {
nextUpEpisode = nextUpEpisode, SeriesActionButtons(
selectedSeason = selectedSeason, nextUpEpisode = nextUpEpisode,
seriesDownloadState = seriesDownloadState, selectedSeason = selectedSeason,
seasonDownloadState = seasonDownloadState, seriesDownloadState = seriesDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled, seasonDownloadState = seasonDownloadState,
offline = offline, isSmartDownloadEnabled = isSmartDownloadEnabled,
onDownloadOptionSelected = { option -> offline = offline,
onDownloadOptionSelected(option, selectedSeason) onDownloadOptionSelected = { option ->
} onDownloadOptionSelected(option, selectedSeason)
) }
)
}
MediaSynopsis( MediaSynopsis(
synopsis = series.synopsis, synopsis = series.synopsis,
bodyColor = scheme.onSurface, bodyColor = scheme.onSurface,
@@ -216,14 +218,22 @@ private fun SeriesScreenInternal(
bodyLineHeight = null, bodyLineHeight = null,
titleSpacing = 8.dp titleSpacing = 8.dp
) )
SeasonTabs( if (selectedSeason != null) {
seasons = series.seasons, SeasonTabs(
selectedSeason = selectedSeason, seasons = series.seasons,
onSelect = { selectedSeasonId = it.id } selectedSeason = selectedSeason,
) onSelect = { selectedSeasonId = it.id }
EpisodeCarousel( )
episodes = selectedSeason.episodes, EpisodeCarousel(
) episodes = selectedSeason.episodes,
)
} else {
Text(
text = "Loading seasons...",
color = scheme.onSurfaceVariant,
fontSize = 13.sp
)
}
if (series.cast.isNotEmpty()) { if (series.cast.isNotEmpty()) {
Text( Text(
text = "Cast", text = "Cast",

View File

@@ -4,7 +4,6 @@ import android.content.Intent
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.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
@@ -20,8 +19,8 @@ import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
@@ -333,14 +332,23 @@ internal fun SeasonTabs(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onSelect: (Season) -> Unit 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 modifier = modifier
.fillMaxWidth() .fillMaxWidth(),
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(20.dp), horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
seasons.forEachIndexed { index, season -> itemsIndexed(seasons, key = { _, season -> season.id }) { index, season ->
SeasonTab( SeasonTab(
name = season.name, name = season.name,
isSelected = season == selectedSeason, isSelected = season == selectedSeason,

View File

@@ -332,7 +332,7 @@ class JellyfinApiClient @Inject constructor(
userId = getUserId(), userId = getUserId(),
seriesId = seriesId, seriesId = seriesId,
fields = defaultItemFields, fields = defaultItemFields,
enableUserData = true, enableUserData = false,
) )
result.content.items result.content.items
} }