Show loading/error for large seasons

This commit is contained in:
Damontecres 2025-10-15 13:27:48 -04:00
parent ba45c3f509
commit d4b3c554f8
No known key found for this signature in database
8 changed files with 173 additions and 112 deletions

View file

@ -72,6 +72,7 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = "11"
@ -227,4 +228,5 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
coreLibraryDesugaring(libs.desugar.jdk.libs)
}

View file

@ -31,9 +31,6 @@ data class BaseItem(
val subtitle =
if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString()
@Transient
val indexNumber = data.indexNumber
@Transient
val resumeMs =
data.userData
@ -41,12 +38,23 @@ data class BaseItem(
?.ticks
?.inWholeMilliseconds
@Transient
val indexNumber = data.indexNumber ?: dateAsIndex()
private fun dateAsIndex(): Int? =
data.premiereDate
?.let {
it.year.toString() +
it.monthValue.toString().padStart(2, '0') +
it.dayOfMonth.toString().padStart(2, '0')
}?.toIntOrNull()
fun destination(): Destination {
val result =
// Redirect episodes & seasons to their series if possible
when (type) {
BaseItemKind.EPISODE -> {
data.indexNumber?.let { episode ->
indexNumber?.let { episode ->
data.parentIndexNumber?.let { season ->
Destination.SeriesOverview(
data.seriesId!!,

View file

@ -63,7 +63,7 @@ class SeriesViewModel
private lateinit var prefs: UserPreferences
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
val episodes = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
val people = MutableLiveData<List<Person>>(listOf())
fun init(
@ -82,7 +82,6 @@ class SeriesViewModel
) + Dispatchers.IO,
) {
val item = fetchItem(seriesId, potential)
if (item != null) {
val seasonsInfo = getSeasons(item)
// If a particular season was requested, fetch those episodes, otherwise get the first season
@ -90,7 +89,7 @@ class SeriesViewModel
(season ?: seasonsInfo.items.firstOrNull()?.indexNumber)
?.let { seasonNum ->
loadEpisodesInternal(seasonNum)
} ?: ItemListAndMapping.empty()
} ?: EpisodeList.Error("Could not determine season")
withContext(Dispatchers.Main) {
seasons.value = seasonsInfo
episodes.value = episodeInfo
@ -102,13 +101,6 @@ class SeriesViewModel
}.orEmpty()
}
maybePlayThemeSong(prefs.appPreferences.interfacePreferences.playThemeSongs)
} else {
withContext(Dispatchers.Main) {
seasons.value = ItemListAndMapping.empty()
episodes.value = ItemListAndMapping.empty()
loading.value = LoadingState.Error("Series $seriesId not found")
}
}
}
}
@ -203,7 +195,7 @@ class SeriesViewModel
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
}
private suspend fun loadEpisodesInternal(season: Int): ItemListAndMapping {
private suspend fun loadEpisodesInternal(season: Int): EpisodeList {
val request =
GetEpisodesRequest(
seriesId = item.value!!.id,
@ -222,23 +214,24 @@ class SeriesViewModel
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
pager.init()
Timber.Forest.v("Loaded ${pager.size} episodes for season $season")
return convertPager(pager)
return EpisodeList.Success(convertPager(pager))
}
fun loadEpisodes(season: Int) =
fun loadEpisodes(season: Int) {
this@SeriesViewModel.episodes.value = EpisodeList.Loading
viewModelScope.async(ExceptionHandler(true)) {
val episodes =
try {
loadEpisodesInternal(season)
} catch (e: Exception) {
Timber.e(e, "Error loading episodes for $seriesId for season $season")
// TODO show error in UI?
ItemListAndMapping.empty()
EpisodeList.Error(e)
}
withContext(Dispatchers.Main) {
this@SeriesViewModel.episodes.value = episodes
}
}
}
fun setWatched(
itemId: UUID,
@ -272,14 +265,15 @@ class SeriesViewModel
val base = api.userLibraryApi.getItem(itemId).content
val item = BaseItem.Companion.from(base, api)
val eps = episodes.value!!
withContext(Dispatchers.Main) {
episodes.value =
eps.copy(
items =
eps.items.toMutableList().apply {
if (eps is EpisodeList.Success) {
val newItems =
eps.episodes.items.toMutableList().apply {
this[listIndex] = item
},
)
}
val newValue = EpisodeList.Success(eps.episodes.copy(items = newItems))
withContext(Dispatchers.Main) {
episodes.value = newValue
}
}
}
@ -325,17 +319,32 @@ data class ItemListAndMapping(
}
/**
* Calculate the index<->season number pairings
* Calculate the index<->season/ep number pairings
*
* This allows for handling of missing seasons
*/
private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping {
val pairs =
pager.mapIndexed { index, _ ->
val season = pager.getBlocking(index)
Pair(season?.indexNumber!!, index)
val item = pager.getBlocking(index)
Pair(item?.indexNumber ?: index, index)
}
val seasonNumToIndex = mapOf(*pairs.toTypedArray())
val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
}
sealed interface EpisodeList {
data object Loading : EpisodeList
data class Error(
val message: String? = null,
val exception: Throwable? = null,
) : EpisodeList {
constructor(exception: Throwable) : this(null, exception)
}
data class Success(
val episodes: ItemListAndMapping,
) : EpisodeList
}

View file

@ -25,6 +25,7 @@ import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.dolphin.ui.detail.EpisodeList
import com.github.damontecres.dolphin.ui.detail.ItemListAndMapping
import com.github.damontecres.dolphin.ui.detail.SeriesViewModel
import com.github.damontecres.dolphin.ui.nav.Destination
@ -75,7 +76,8 @@ fun SeriesOverview(
val series by viewModel.item.observeAsState(null)
val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty())
val episodes by viewModel.episodes.observeAsState(ItemListAndMapping.empty())
val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading)
val episodeList = (episodes as? EpisodeList.Success)?.episodes?.items
var position by rememberSaveable(
destination,
@ -89,7 +91,10 @@ fun SeriesOverview(
mutableStateOf(
SeriesOverviewPosition(
seasons.numberToIndex[initialSeasonEpisode?.season ?: 0] ?: 0,
episodes.numberToIndex[initialSeasonEpisode?.episode ?: 0] ?: 0,
(episodes as? EpisodeList.Success)?.episodes?.numberToIndex[
initialSeasonEpisode?.episode
?: 0,
] ?: 0,
),
)
}
@ -97,15 +102,19 @@ fun SeriesOverview(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
LaunchedEffect(episodes.items) {
if (episodes.items.isNotEmpty()) {
LaunchedEffect(episodes) {
episodes?.let { episodes ->
if (episodes is EpisodeList.Success) {
if (episodes.episodes.items.isNotEmpty()) {
// TODO focus on first episode when changing seasons?
// firstItemFocusRequester.requestFocus()
episodes.items.getOrNull(position.episodeRowIndex)?.let {
episodes.episodes.items.getOrNull(position.episodeRowIndex)?.let {
viewModel.refreshEpisode(it.id, position.episodeRowIndex)
}
}
}
}
}
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
@ -120,7 +129,7 @@ fun SeriesOverview(
SeriesOverviewContent(
series = series,
seasons = seasons.items,
episodes = episodes.items,
episodes = episodes,
position = position,
backdropImageUrl =
remember {
@ -140,7 +149,6 @@ fun SeriesOverview(
position = it
},
onClick = {
viewModel.release()
val resumePosition =
it.data.userData
?.playbackPositionTicks
@ -157,7 +165,7 @@ fun SeriesOverview(
// TODO
},
playOnClick = { resume ->
episodes.items.getOrNull(position.episodeRowIndex)?.let {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
viewModel.release()
viewModel.navigateTo(
Destination.Playback(
@ -169,13 +177,13 @@ fun SeriesOverview(
}
},
watchOnClick = {
episodes.items.getOrNull(position.episodeRowIndex)?.let {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
val played = it.data.userData?.played ?: false
viewModel.setWatched(it.id, !played, position.episodeRowIndex)
}
},
moreOnClick = {
episodes.items.getOrNull(position.episodeRowIndex)?.let { ep ->
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
moreDialog =
DialogParams(
fromLongClick = false,
@ -227,7 +235,7 @@ fun SeriesOverview(
}
},
overviewOnClick = {
episodes.items.getOrNull(position.episodeRowIndex)?.let {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
overviewDialog =
ItemDetailsDialogInfo(
title = it.name ?: "Unknown",

View file

@ -46,16 +46,20 @@ import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.aspectRatioFloat
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.dolphin.ui.cards.BannerCard
import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.detail.EpisodeList
import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.formatDateTime
import kotlin.time.Duration
@Composable
fun SeriesOverviewContent(
series: BaseItem,
seasons: List<BaseItem?>,
episodes: List<BaseItem?>,
episodes: EpisodeList,
position: SeriesOverviewPosition,
backdropImageUrl: String?,
firstItemFocusRequester: FocusRequester,
@ -76,7 +80,8 @@ fun SeriesOverviewContent(
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
val tabRowFocusRequester = remember { FocusRequester() }
val focusedEpisode = episodes.getOrNull(position.episodeRowIndex)
val focusedEpisode =
(episodes as? EpisodeList.Success)?.episodes?.items?.getOrNull(position.episodeRowIndex)
Box(
modifier =
@ -200,12 +205,16 @@ fun SeriesOverviewContent(
}
item {
key(position.seasonTabIndex) {
when (val eps = episodes) {
EpisodeList.Loading -> LoadingPage()
is EpisodeList.Error -> ErrorMessage(eps.message, eps.exception)
is EpisodeList.Success -> {
val state = rememberLazyListState()
OneTimeLaunchedEffect {
if (state.firstVisibleItemIndex != position.episodeRowIndex) {
state.scrollToItem(position.episodeRowIndex)
firstItemFocusRequester.tryRequestFocus()
}
firstItemFocusRequester.tryRequestFocus()
}
LazyRow(
state = state,
@ -216,7 +225,7 @@ fun SeriesOverviewContent(
.focusRestorer(firstItemFocusRequester)
.focusRequester(episodeRowFocusRequester),
) {
itemsIndexed(episodes) { episodeIndex, episode ->
itemsIndexed(eps.episodes.items) { episodeIndex, episode ->
val interactionSource = remember { MutableInteractionSource() }
if (interactionSource.collectIsFocusedAsState().value) {
onFocus.invoke(
@ -226,7 +235,9 @@ fun SeriesOverviewContent(
),
)
}
val cornerText =
episode?.data?.indexNumber?.let { "E$it" }
?: episode?.data?.premiereDate?.let(::formatDateTime)
BannerCard(
name = episode?.name,
imageUrl = episode?.imageUrl,
@ -235,13 +246,23 @@ fun SeriesOverviewContent(
?.data
?.primaryImageAspectRatio
?.toFloat()
?.coerceAtLeast(episode.data.aspectRatioFloat ?: (4f / 3f))
?.coerceAtLeast(
episode.data.aspectRatioFloat ?: (4f / 3f),
)
?: (16f / 9), // TODO some episode images don't match the file's aspect ratio
cornerText = "E${episode?.data?.indexNumber}",
cornerText = cornerText,
played = episode?.data?.userData?.played ?: false,
playPercent = episode?.data?.userData?.playedPercentage ?: 0.0,
playPercent =
episode?.data?.userData?.playedPercentage
?: 0.0,
onClick = { if (episode != null) onClick.invoke(episode) },
onLongClick = { if (episode != null) onLongClick.invoke(episode) },
onLongClick = {
if (episode != null) {
onLongClick.invoke(
episode,
)
}
},
modifier =
Modifier.ifElse(
episodeIndex == position.episodeRowIndex,
@ -254,6 +275,8 @@ fun SeriesOverviewContent(
}
}
}
}
}
item {
focusedEpisode?.let { ep ->
FocusedEpisodeFooter(

View file

@ -54,6 +54,7 @@ import com.github.damontecres.dolphin.ui.timeRemaining
import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.LoadingState
import com.github.damontecres.dolphin.util.formatDateTime
import com.github.damontecres.dolphin.util.seasonEpisode
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.extensions.ticks
@ -231,7 +232,13 @@ fun MainPageHeader(
val details =
buildList {
if (isEpisode) {
add("S${dto.parentIndexNumber} E${dto.indexNumber}")
val se = dto.seasonEpisode
if (se != null) {
add(se)
} else if (dto.parentIndexNumber != null) {
// Maybe a daily episode, so just show season, the date is added below
add("S${dto.parentIndexNumber}")
}
}
if (isEpisode) {
dto.premiereDate?.let { add(formatDateTime(it)) }
@ -271,13 +278,13 @@ fun MainPageHeader(
val overviewModifier =
Modifier
.padding(0.dp)
.height(48.dp)
.height(48.dp + if (!isEpisode) 12.dp else 0.dp)
if (overview.isNotNullOrBlank()) {
Text(
text = overview,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
maxLines = if (isEpisode) 2 else 3,
overflow = TextOverflow.Ellipsis,
modifier = overviewModifier,
)

View file

@ -13,6 +13,8 @@ import java.time.format.DateTimeFormatter
*/
fun formatDateTime(dateTime: LocalDateTime): String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// TODO server returns in UTC, but sdk converts to local time
// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
formatter.format(dateTime)
} else if (dateTime.toString().length >= 10) {

View file

@ -1,6 +1,7 @@
[versions]
aboutLibraries = "12.2.4"
agp = "8.13.0"
desugar_jdk_libs = "2.1.5"
hiltNavigationCompose = "1.3.0"
kotlin = "2.2.20"
ksp = "2.2.20-2.0.2" # https://github.com/google/ksp/issues/2596 2.0.3 is broken
@ -51,6 +52,7 @@ androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.re
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" }