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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -332,7 +332,7 @@ class JellyfinApiClient @Inject constructor(
|
||||
userId = getUserId(),
|
||||
seriesId = seriesId,
|
||||
fields = defaultItemFields,
|
||||
enableUserData = true,
|
||||
enableUserData = false,
|
||||
)
|
||||
result.content.items
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user