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.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? {

View File

@@ -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<Season>,
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(