mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
feat(series): improve season and episode loading handling
This commit is contained in:
@@ -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? {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user