Handle mapping missing seasons or episodes

This commit is contained in:
Damontecres 2025-10-03 18:25:00 -04:00
parent 6d3e44c877
commit f8efffa3c2
No known key found for this signature in database
5 changed files with 84 additions and 45 deletions

View file

@ -29,7 +29,7 @@ fun SeriesDetails(
viewModel.init(destination.itemId, destination.item, null, null) viewModel.init(destination.itemId, destination.item, null, null)
} }
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val seasons by viewModel.seasons.observeAsState(listOf()) val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty())
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val state = loading) { when (val state = loading) {
@ -44,7 +44,7 @@ fun SeriesDetails(
item { item {
ItemRow( ItemRow(
title = "Seasons", title = "Seasons",
items = seasons, items = seasons.items,
onClickItem = { navigationManager.navigateTo(it.destination()) }, onClickItem = { navigationManager.navigateTo(it.destination()) },
onLongClickItem = { }, onLongClickItem = { },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),

View file

@ -52,8 +52,8 @@ class SeriesViewModel
private var player: Player? = null private var player: Player? = null
private lateinit var seriesId: UUID private lateinit var seriesId: UUID
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<List<BaseItem?>>(listOf()) val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
val episodes = MutableLiveData<List<BaseItem?>>(listOf()) val episodes = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
fun init( fun init(
itemId: UUID, itemId: UUID,
@ -70,22 +70,22 @@ class SeriesViewModel
) { ) {
val item = fetchItem(seriesId, potential) val item = fetchItem(seriesId, potential)
if (item != null) { if (item != null) {
val seasonPager = getSeasons(item) val seasonsInfo = getSeasons(item)
val episodePager = val episodeInfo =
season?.let { seasonNum -> (season ?: seasonsInfo.items.firstOrNull()?.indexNumber)
// TODO map season number to index in list ?.let { seasonNum ->
loadEpisodesInternal(seasonNum) loadEpisodesInternal(seasonNum)
} } ?: ItemListAndMapping.empty()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
seasons.value = seasonPager.orEmpty() seasons.value = seasonsInfo
episodes.value = episodePager.orEmpty() episodes.value = episodeInfo
loading.value = LoadingState.Success loading.value = LoadingState.Success
} }
maybePlayThemeSong() maybePlayThemeSong()
} else { } else {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
seasons.value = listOf() seasons.value = ItemListAndMapping.empty()
episodes.value = listOf() episodes.value = ItemListAndMapping.empty()
loading.value = LoadingState.Error("Series $seriesId not found") loading.value = LoadingState.Error("Series $seriesId not found")
} }
} }
@ -135,7 +135,7 @@ class SeriesViewModel
player = null player = null
} }
private suspend fun getSeasons(item: BaseItem): ApiRequestPager<GetItemsRequest>? { private suspend fun getSeasons(item: BaseItem): ItemListAndMapping {
val request = val request =
GetItemsRequest( GetItemsRequest(
parentId = item.id, parentId = item.id,
@ -164,12 +164,12 @@ class SeriesViewModel
val season = pager.getBlocking(index) val season = pager.getBlocking(index)
Pair(season?.indexNumber!!, index) Pair(season?.indexNumber!!, index)
} }
mapOf(*pairs.toTypedArray()) val seasonNumToIndex = mapOf(*pairs.toTypedArray())
mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray()) val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
return pager return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
} }
private suspend fun loadEpisodesInternal(season: Int): ApiRequestPager<GetEpisodesRequest> { private suspend fun loadEpisodesInternal(season: Int): ItemListAndMapping {
val request = val request =
GetEpisodesRequest( GetEpisodesRequest(
seriesId = item.value!!.id, seriesId = item.value!!.id,
@ -188,21 +188,21 @@ class SeriesViewModel
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
pager.init() pager.init()
Timber.Forest.v("Loaded ${pager.size} episodes for season $season") Timber.Forest.v("Loaded ${pager.size} episodes for season $season")
return pager return convertPager(pager)
} }
fun loadEpisodes(season: Int) = fun loadEpisodes(season: Int) =
viewModelScope.async(ExceptionHandler(true)) { viewModelScope.async(ExceptionHandler(true)) {
val episodePager = val episodes =
try { try {
loadEpisodesInternal(season) loadEpisodesInternal(season)
} catch (e: Exception) { } catch (e: Exception) {
Timber.Forest.e(e, "Error loading episodes for $seriesId for season $season") Timber.Forest.e(e, "Error loading episodes for $seriesId for season $season")
// TODO show error in UI? // TODO show error in UI?
listOf() ItemListAndMapping.empty()
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
episodes.value = episodePager this@SeriesViewModel.episodes.value = episodes
} }
} }
@ -225,9 +225,34 @@ class SeriesViewModel
) = viewModelScope.launch(ExceptionHandler()) { ) = viewModelScope.launch(ExceptionHandler()) {
val base = api.userLibraryApi.getItem(itemId).content val base = api.userLibraryApi.getItem(itemId).content
val item = BaseItem.Companion.from(base, api) val item = BaseItem.Companion.from(base, api)
val eps = episodes.value!!
episodes.value = episodes.value =
episodes.value!!.toMutableList().apply { eps.copy(
items =
eps.items.toMutableList().apply {
this[listIndex] = item this[listIndex] = item
},
)
} }
} }
data class ItemListAndMapping(
val items: List<BaseItem?>,
val numberToIndex: Map<Int, Int>,
val indexToNumber: Map<Int, Int>,
) {
companion object {
fun empty() = ItemListAndMapping(listOf(), mapOf(), mapOf())
} }
}
private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping {
val pairs =
pager.mapIndexed { index, _ ->
val season = pager.getBlocking(index)
Pair(season?.indexNumber!!, index)
}
val seasonNumToIndex = mapOf(*pairs.toTypedArray())
val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
}

View file

@ -18,6 +18,7 @@ import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.LoadingPage import com.github.damontecres.dolphin.ui.components.LoadingPage
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.dolphin.ui.detail.ItemListAndMapping
import com.github.damontecres.dolphin.ui.detail.SeriesViewModel import com.github.damontecres.dolphin.ui.detail.SeriesViewModel
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.nav.NavigationManager
@ -48,7 +49,7 @@ fun SeriesOverview(
destination: Destination.MediaItem, destination: Destination.MediaItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel(), viewModel: SeriesViewModel = hiltViewModel(),
initialSeasonEpisode: SeasonEpisode = SeasonEpisode(0, 0), initialSeasonEpisode: SeasonEpisode? = null,
) { ) {
val firstItemFocusRequester = remember { FocusRequester() } val firstItemFocusRequester = remember { FocusRequester() }
val episodeRowFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() }
@ -58,19 +59,20 @@ fun SeriesOverview(
viewModel.init( viewModel.init(
destination.itemId, destination.itemId,
destination.item, destination.item,
initialSeasonEpisode.season, initialSeasonEpisode?.season,
initialSeasonEpisode.episode, initialSeasonEpisode?.episode,
) )
} }
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val series by viewModel.item.observeAsState(null) val series by viewModel.item.observeAsState(null)
val seasons by viewModel.seasons.observeAsState(listOf()) val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty())
val episodes by viewModel.episodes.observeAsState(listOf()) val episodes by viewModel.episodes.observeAsState(ItemListAndMapping.empty())
// TODO need to translate season/episode into tab/row index in case of missing seasons/episodes // TODO need to translate season/episode into tab/row index in case of missing seasons/episodes
var position by rememberSaveable( var position by rememberSaveable(
destination, destination,
loading,
stateSaver = stateSaver =
Saver( Saver(
save = { listOf(it.seasonTabIndex, it.episodeRowIndex) }, save = { listOf(it.seasonTabIndex, it.episodeRowIndex) },
@ -79,19 +81,19 @@ fun SeriesOverview(
) { ) {
mutableStateOf( mutableStateOf(
SeriesOverviewPosition( SeriesOverviewPosition(
initialSeasonEpisode.season, seasons.numberToIndex[initialSeasonEpisode?.season ?: 0] ?: 0,
initialSeasonEpisode.episode, episodes.numberToIndex[initialSeasonEpisode?.episode ?: 0] ?: 0,
), ),
) )
} }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
LaunchedEffect(episodes) { LaunchedEffect(episodes.items) {
if (episodes.isNotEmpty()) { if (episodes.items.isNotEmpty()) {
// TODO focus on first episode when changing seasons // TODO focus on first episode when changing seasons
// firstItemFocusRequester.requestFocus() // firstItemFocusRequester.requestFocus()
episodes.getOrNull(position.episodeRowIndex)?.let { episodes.items.getOrNull(position.episodeRowIndex)?.let {
viewModel.refreshEpisode(it.id, position.episodeRowIndex) viewModel.refreshEpisode(it.id, position.episodeRowIndex)
} }
} }
@ -107,8 +109,8 @@ fun SeriesOverview(
LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() } LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() }
SeriesOverviewContent( SeriesOverviewContent(
series = series, series = series,
seasons = seasons, seasons = seasons.items,
episodes = episodes, episodes = episodes.items,
position = position, position = position,
backdropImageUrl = backdropImageUrl =
remember { remember {
@ -121,7 +123,9 @@ fun SeriesOverview(
episodeRowFocusRequester = episodeRowFocusRequester, episodeRowFocusRequester = episodeRowFocusRequester,
onFocus = { onFocus = {
if (it.seasonTabIndex != position.seasonTabIndex) { if (it.seasonTabIndex != position.seasonTabIndex) {
viewModel.loadEpisodes(seasons[it.seasonTabIndex]!!.indexNumber!!) seasons.indexToNumber[it.seasonTabIndex]?.let { seasonNumber ->
viewModel.loadEpisodes(seasonNumber)
}
} }
position = it position = it
}, },
@ -143,7 +147,7 @@ fun SeriesOverview(
// TODO // TODO
}, },
playOnClick = { resume -> playOnClick = { resume ->
episodes.getOrNull(position.episodeRowIndex)?.let { episodes.items.getOrNull(position.episodeRowIndex)?.let {
viewModel.release() viewModel.release()
navigationManager.navigateTo( navigationManager.navigateTo(
Destination.Playback( Destination.Playback(
@ -155,7 +159,7 @@ fun SeriesOverview(
} }
}, },
watchOnClick = { watchOnClick = {
episodes.getOrNull(position.episodeRowIndex)?.let { episodes.items.getOrNull(position.episodeRowIndex)?.let {
val played = it.data.userData?.played ?: false val played = it.data.userData?.played ?: false
viewModel.setWatched(it.id, !played, position.episodeRowIndex) viewModel.setWatched(it.id, !played, position.episodeRowIndex)
} }
@ -164,7 +168,7 @@ fun SeriesOverview(
// TODO show more actions // TODO show more actions
}, },
overviewOnClick = { overviewOnClick = {
episodes.getOrNull(position.episodeRowIndex)?.let { episodes.items.getOrNull(position.episodeRowIndex)?.let {
overviewDialog = overviewDialog =
ItemDetailsDialogInfo( ItemDetailsDialogInfo(
title = it.name ?: "Unknown", title = it.name ?: "Unknown",

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@ -40,9 +41,11 @@ import androidx.tv.material3.TabRow
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.dolphin.ui.cards.BannerCard import com.github.damontecres.dolphin.ui.cards.BannerCard
import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.tryRequestFocus
import kotlin.time.Duration import kotlin.time.Duration
@Composable @Composable
@ -71,6 +74,8 @@ fun SeriesOverviewContent(
val tabRowFocusRequester = remember { FocusRequester() } val tabRowFocusRequester = remember { FocusRequester() }
val focusedEpisode = episodes.getOrNull(position.episodeRowIndex) val focusedEpisode = episodes.getOrNull(position.episodeRowIndex)
LaunchedEffect(position) {
}
Box( Box(
modifier = modifier =
@ -174,6 +179,12 @@ fun SeriesOverviewContent(
item { item {
key(position.seasonTabIndex) { key(position.seasonTabIndex) {
val state = rememberLazyListState() val state = rememberLazyListState()
OneTimeLaunchedEffect {
if (state.firstVisibleItemIndex != position.episodeRowIndex) {
state.scrollToItem(position.episodeRowIndex)
firstItemFocusRequester.tryRequestFocus()
}
}
LazyRow( LazyRow(
state = state, state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
@ -205,7 +216,7 @@ fun SeriesOverviewContent(
onLongClick = { if (episode != null) onLongClick.invoke(episode) }, onLongClick = { if (episode != null) onLongClick.invoke(episode) },
modifier = modifier =
Modifier.ifElse( Modifier.ifElse(
episodeIndex == 0, episodeIndex == position.episodeRowIndex,
Modifier.focusRequester(firstItemFocusRequester), Modifier.focusRequester(firstItemFocusRequester),
), ),
interactionSource = interactionSource, interactionSource = interactionSource,

View file

@ -9,7 +9,6 @@ import com.github.damontecres.dolphin.ui.detail.EpisodeDetails
import com.github.damontecres.dolphin.ui.detail.SeasonDetails import com.github.damontecres.dolphin.ui.detail.SeasonDetails
import com.github.damontecres.dolphin.ui.detail.VideoDetails import com.github.damontecres.dolphin.ui.detail.VideoDetails
import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails
import com.github.damontecres.dolphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview
import com.github.damontecres.dolphin.ui.main.MainPage import com.github.damontecres.dolphin.ui.main.MainPage
import com.github.damontecres.dolphin.ui.playback.PlaybackContent import com.github.damontecres.dolphin.ui.playback.PlaybackContent
@ -64,7 +63,7 @@ fun DestinationContent(
navigationManager, navigationManager,
destination, destination,
modifier, modifier,
initialSeasonEpisode = destination.seasonEpisode ?: SeasonEpisode(0, 0), initialSeasonEpisode = destination.seasonEpisode,
) )
BaseItemKind.SEASON -> BaseItemKind.SEASON ->