From 0fb1a69556274d76b21feee244c62c7f6070d7a2 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sun, 9 Nov 2025 17:40:13 -0500 Subject: [PATCH 1/2] Fix only loading 100 episode in a season (#187) Fixes #184 Data was being fetched, but the UI wasn't being properly notified to display the data. This PR also adjusts so that "focused episode header" still takes up the space even if the focused episode hasn't fully loaded yet. This prevents the UI from jumping up and down. Also #42 is _not_ fixed by this PR because it is a different issue. --- .../ui/detail/series/FocusedEpisodeHeader.kt | 20 ++++++------ .../ui/detail/series/SeriesOverviewContent.kt | 12 +++---- .../ui/detail/series/SeriesViewModel.kt | 12 ++----- .../wholphin/util/ApiRequestPager.kt | 31 +++++++++++++++++-- 4 files changed, 47 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index 78775e03..77d7e386 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -24,18 +24,18 @@ import org.jellyfin.sdk.model.extensions.ticks @Composable fun FocusedEpisodeHeader( - ep: BaseItem, + ep: BaseItem?, overviewOnClick: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current - val dto = ep.data + val dto = ep?.data Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { Text( - text = dto.episodeTitle ?: dto.name ?: "", + text = dto?.episodeTitle ?: dto?.name ?: "", style = MaterialTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -46,17 +46,17 @@ fun FocusedEpisodeHeader( ) { val details = buildList { - dto.seasonEpisode?.let(::add) - dto.premiereDate?.let { add(formatDateTime(it)) } - val duration = dto.runTimeTicks?.ticks + dto?.seasonEpisode?.let(::add) + dto?.premiereDate?.let { add(formatDateTime(it)) } + val duration = dto?.runTimeTicks?.ticks duration ?.roundMinutes ?.toString() ?.let { add(it) } - dto.officialRating?.let(::add) - dto.timeRemaining?.roundMinutes?.let { add("$it left") } + dto?.officialRating?.let(::add) + dto?.timeRemaining?.roundMinutes?.let { add("$it left") } } DotSeparatedRow( texts = details, @@ -68,12 +68,12 @@ fun FocusedEpisodeHeader( verticalAlignment = Alignment.CenterVertically, ) { SimpleStarRating( - dto.communityRating, + dto?.communityRating, Modifier.height(20.dp), ) } OverviewText( - overview = dto.overview ?: "", + overview = dto?.overview ?: "", maxLines = 3, onClick = overviewOnClick, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index f2fde9f3..ce73e872 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -162,13 +162,11 @@ fun SeriesOverviewContent( } item { // Episode header - focusedEpisode?.let { ep -> - FocusedEpisodeHeader( - ep = ep, - overviewOnClick = overviewOnClick, - modifier = Modifier.fillMaxWidth(.66f), - ) - } + FocusedEpisodeHeader( + ep = focusedEpisode, + overviewOnClick = overviewOnClick, + modifier = Modifier.fillMaxWidth(.66f), + ) } item { key(position.seasonTabIndex) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 8acb295e..36b8b124 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -293,17 +293,11 @@ class SeriesViewModel itemId: UUID, listIndex: Int, ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - val base = api.userLibraryApi.getItem(itemId).content - val item = BaseItem.Companion.from(base, api) val eps = episodes.value if (eps is EpisodeList.Success) { - val newItems = - eps.episodes.toMutableList().apply { - this[listIndex] = item - } - val newValue = eps.copy(episodes = newItems) + eps.episodes.refreshItem(listIndex, itemId) withContext(Dispatchers.Main) { - episodes.value = newValue + episodes.value = eps } } } @@ -409,7 +403,7 @@ sealed interface EpisodeList { } data class Success( - val episodes: List, + val episodes: ApiRequestPager, val initialIndex: Int, ) : EpisodeList } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index bde82618..2cc5bb3d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -21,6 +21,7 @@ import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.playlistsApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.GetProgramsDto import org.jellyfin.sdk.model.api.request.GetEpisodesRequest @@ -31,6 +32,7 @@ import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber +import java.util.UUID import java.util.function.Predicate /** @@ -58,7 +60,7 @@ class ApiRequestPager( CacheBuilder .newBuilder() .maximumSize(cacheSize) - .build>() + .build>() suspend fun init(): ApiRequestPager { if (totalCount < 0) { @@ -122,13 +124,38 @@ class ApiRequestPager( false, ) val result = requestHandler.execute(api, newRequest).content - val data = result.items.map { BaseItem.from(it, api, useSeriesForPrimary) } + val data = mutableListOf() + result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) } cachedPages.put(pageNumber, data) items = ItemList(totalCount, pageSize, cachedPages.asMap()) } } } + suspend fun refreshItem( + position: Int, + itemId: UUID, + ) { + val item = + api.userLibraryApi.getItem(itemId).content.let { + BaseItem.from( + it, + api, + useSeriesForPrimary, + ) + } + val pageNumber = position / pageSize + val index = position - pageNumber * pageSize + mutex.withLock { + val page = cachedPages.getIfPresent(pageNumber) + if (page != null && index in page.indices) { + page[index] = item + cachedPages.put(pageNumber, page) + items = ItemList(totalCount, pageSize, cachedPages.asMap()) + } + } + } + companion object { private const val DEBUG = false } From 98fc996bd5df487e4d09b3b01071aa11188f740d Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sun, 9 Nov 2025 17:40:47 -0500 Subject: [PATCH 2/2] Load movie & tv recommendations asynchronously (#183) Previously all of the rows on the recommended tab for Movies & TV Shows were loaded sequentially before any results were shown. This is not a great experience because some of the queries (eg top rated unwatched) can take a while sometimes. This PR starts displaying content once the first 1 or 2 rows is loaded and then shows placeholders while the rest are querying. Finally the row queries are now mostly independent so if one errors out, the rest of the rows will still be displayed. Closes #180 --- .../ui/components/RecommendedMovie.kt | 146 ++++++++++---- .../ui/components/RecommendedTvShow.kt | 178 ++++++++++++++---- .../ui/detail/movie/MovieViewModel.kt | 2 +- .../damontecres/wholphin/ui/main/HomePage.kt | 167 +++++++++------- .../wholphin/ui/main/HomeViewModel.kt | 82 ++++---- .../damontecres/wholphin/util/LoadingState.kt | 26 +++ 6 files changed, 422 insertions(+), 179 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index e56747a0..df1f80bb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -1,6 +1,8 @@ package com.github.damontecres.wholphin.ui.components +import android.content.Context import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier @@ -15,16 +17,23 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent -import com.github.damontecres.wholphin.ui.main.HomeRow import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -34,33 +43,73 @@ import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest +import timber.log.Timber import java.util.UUID -import javax.inject.Inject -@HiltViewModel +@HiltViewModel(assistedFactory = RecommendedMovieViewModel.Factory::class) class RecommendedMovieViewModel - @Inject + @AssistedInject constructor( + @param:ApplicationContext private val context: Context, private val api: ApiClient, private val serverRepository: ServerRepository, + @Assisted val parentId: UUID, ) : ViewModel() { - val loading = MutableLiveData(LoadingState.Loading) - val rows = MutableLiveData>() + @AssistedFactory + interface Factory { + fun create(parentId: UUID): RecommendedMovieViewModel + } - fun init( - preferences: UserPreferences, - parentId: UUID, - ) { + val loading = MutableLiveData(LoadingState.Loading) + + val rows = + MutableStateFlow>( + rowTitles + .map { + HomeRowLoadingState.Pending( + context.getString(it), + ) + }.toMutableList(), + ) + + fun init() { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { - val resumeItemsRequest = - GetResumeItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - enableUserData = true, + try { + val resumeItemsRequest = + GetResumeItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + enableUserData = true, + ) + val resumeItems = + ApiRequestPager( + api, + resumeItemsRequest, + GetResumeItemsRequestHandler, + viewModelScope, + ).init() + if (resumeItems.isNotEmpty()) { + resumeItems.getBlocking(0) + } + + update( + 0, + HomeRowLoadingState.Success( + context.getString(R.string.continue_watching), + resumeItems, + ), ) - val resumeItems = - ApiRequestPager(api, resumeItemsRequest, GetResumeItemsRequestHandler, viewModelScope) + + withContext(Dispatchers.Main) { + loading.value = LoadingState.Success + } + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching movie recommendations") + withContext(Dispatchers.Main) { + loading.value = LoadingState.Error(ex) + } + } val recentlyReleasedRequest = GetItemsRequest( @@ -109,22 +158,50 @@ class RecommendedMovieViewModel val unwatchedTopRatedItems = ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) - val rows = listOf(resumeItems, recentlyReleasedItems, recentlyAddedItems, suggestedItems, unwatchedTopRatedItems) - rows.forEach { it.init() } - val homeRows = + val rows = listOf( - HomeRow(R.string.continue_watching, resumeItems), - HomeRow(R.string.recently_released, recentlyReleasedItems), - HomeRow(R.string.recently_added, recentlyAddedItems), - HomeRow(R.string.suggestions, suggestedItems), - HomeRow(R.string.top_unwatched, unwatchedTopRatedItems), - ).filter { it.items.isNotEmpty() } - withContext(Dispatchers.Main) { - this@RecommendedMovieViewModel.rows.value = homeRows - loading.value = LoadingState.Success + R.string.recently_released to recentlyReleasedItems, + R.string.recently_added to recentlyAddedItems, + R.string.suggestions to suggestedItems, + R.string.top_unwatched to unwatchedTopRatedItems, + ) + + rows.forEachIndexed { index, (title, pager) -> + viewModelScope.launchIO { + val title = context.getString(title) + val result = + try { + pager.init() + HomeRowLoadingState.Success(title, pager) + } catch (ex: Exception) { + Timber.e(ex, "Error fetching %s", title) + HomeRowLoadingState.Error(title, null, ex) + } + update(index + 1, result) + } } } } + + private fun update( + position: Int, + row: HomeRowLoadingState, + ) { + rows.update { current -> + current.apply { set(position, row) } + } + } + + companion object { + private val rowTitles = + listOf( + R.string.continue_watching, + R.string.recently_released, + R.string.recently_added, + R.string.suggestions, + R.string.top_unwatched, + ) + } } /** @@ -137,13 +214,16 @@ fun RecommendedMovie( onClickItem: (BaseItem) -> Unit, onFocusPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, - viewModel: RecommendedMovieViewModel = hiltViewModel(), + viewModel: RecommendedMovieViewModel = + hiltViewModel( + creationCallback = { it.create(parentId) }, + ), ) { OneTimeLaunchedEffect { - viewModel.init(preferences, parentId) + viewModel.init() } val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val rows by viewModel.rows.observeAsState(listOf()) + val rows by viewModel.rows.collectAsState(listOf()) when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index 98b03cb4..1ce8cff2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -1,6 +1,8 @@ package com.github.damontecres.wholphin.ui.components +import android.content.Context import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier @@ -15,17 +17,24 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent -import com.github.damontecres.wholphin.ui.main.HomeRow import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetNextUpRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -36,41 +45,99 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest +import timber.log.Timber import java.util.UUID -import javax.inject.Inject -@HiltViewModel +@HiltViewModel(assistedFactory = RecommendedTvShowViewModel.Factory::class) class RecommendedTvShowViewModel - @Inject + @AssistedInject constructor( + @param:ApplicationContext private val context: Context, private val api: ApiClient, private val serverRepository: ServerRepository, + @Assisted val parentId: UUID, ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(parentId: UUID): RecommendedTvShowViewModel + } + val loading = MutableLiveData(LoadingState.Loading) - val rows = MutableLiveData>() - fun init( - preferences: UserPreferences, - parentId: UUID, - ) { + val rows = + MutableStateFlow>( + rowTitles + .map { + HomeRowLoadingState.Pending( + context.getString(it), + ) + }.toMutableList(), + ) + + fun init() { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { - val resumeItemsRequest = - GetResumeItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.EPISODE), - enableUserData = true, - ) - val resumeItems = - ApiRequestPager(api, resumeItemsRequest, GetResumeItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) + try { + val resumeItemsRequest = + GetResumeItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.EPISODE), + enableUserData = true, + ) + val resumeItems = + ApiRequestPager( + api, + resumeItemsRequest, + GetResumeItemsRequestHandler, + viewModelScope, + useSeriesForPrimary = true, + ).init() + if (resumeItems.isNotEmpty()) { + resumeItems.getBlocking(0) + } - val nextUpRequest = - GetNextUpRequest( - parentId = parentId, - fields = SlimItemFields, - enableUserData = true, + val nextUpRequest = + GetNextUpRequest( + parentId = parentId, + fields = SlimItemFields, + enableUserData = true, + ) + val nextUpItems = + ApiRequestPager( + api, + nextUpRequest, + GetNextUpRequestHandler, + viewModelScope, + useSeriesForPrimary = true, + ).init() + if (nextUpItems.isNotEmpty()) { + nextUpItems.getBlocking(0) + } + + update( + 0, + HomeRowLoadingState.Success( + context.getString(R.string.continue_watching), + resumeItems, + ), ) - val nextUpItems = ApiRequestPager(api, nextUpRequest, GetNextUpRequestHandler, viewModelScope, useSeriesForPrimary = true) + update( + 1, + HomeRowLoadingState.Success( + context.getString(R.string.next_up), + nextUpItems, + ), + ) + + withContext(Dispatchers.Main) { + loading.value = LoadingState.Success + } + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching tv recommendations") + withContext(Dispatchers.Main) { + loading.value = LoadingState.Error(ex) + } + } val recentlyReleasedRequest = GetItemsRequest( @@ -120,23 +187,50 @@ class RecommendedTvShowViewModel ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) val rows = - listOf(resumeItems, nextUpItems, recentlyReleasedItems, recentlyAddedItems, suggestedItems, unwatchedTopRatedItems) - rows.forEach { it.init() } - val homeRows = listOf( - HomeRow(R.string.continue_watching, resumeItems), - HomeRow(R.string.next_up, nextUpItems), - HomeRow(R.string.recently_released, recentlyReleasedItems), - HomeRow(R.string.recently_added, recentlyAddedItems), - HomeRow(R.string.suggestions, suggestedItems), - HomeRow(R.string.top_unwatched, unwatchedTopRatedItems), - ).filter { it.items.isNotEmpty() } - withContext(Dispatchers.Main) { - this@RecommendedTvShowViewModel.rows.value = homeRows - loading.value = LoadingState.Success + R.string.recently_released to recentlyReleasedItems, + R.string.recently_added to recentlyAddedItems, + R.string.suggestions to suggestedItems, + R.string.top_unwatched to unwatchedTopRatedItems, + ) + + rows.forEachIndexed { index, (title, pager) -> + viewModelScope.launchIO { + val title = context.getString(title) + val result = + try { + pager.init() + HomeRowLoadingState.Success(title, pager) + } catch (ex: Exception) { + Timber.e(ex, "Error fetching %s", title) + HomeRowLoadingState.Error(title, null, ex) + } + update(index + 2, result) + } } } } + + private fun update( + position: Int, + row: HomeRowLoadingState, + ) { + rows.update { current -> + current.apply { set(position, row) } + } + } + + companion object { + private val rowTitles = + listOf( + R.string.continue_watching, + R.string.next_up, + R.string.recently_released, + R.string.recently_added, + R.string.suggestions, + R.string.top_unwatched, + ) + } } /** @@ -149,13 +243,17 @@ fun RecommendedTvShow( onClickItem: (BaseItem) -> Unit, onFocusPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, - viewModel: RecommendedTvShowViewModel = hiltViewModel(), + viewModel: RecommendedTvShowViewModel = + hiltViewModel( + creationCallback = { it.create(parentId) }, + ), ) { OneTimeLaunchedEffect { - viewModel.init(preferences, parentId) + viewModel.init() } + val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val rows by viewModel.rows.observeAsState(listOf()) + val rows by viewModel.rows.collectAsState(listOf()) when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 8f7c2cf8..540affd8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -59,7 +59,7 @@ class MovieViewModel ) : ViewModel() { @AssistedFactory interface Factory { - fun create(itemId2: UUID): MovieViewModel + fun create(itemId: UUID): MovieViewModel } val loading = MutableLiveData(LoadingState.Pending) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 7e424cb6..9a189973 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -1,7 +1,6 @@ package com.github.damontecres.wholphin.ui.main import android.widget.Toast -import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -67,6 +66,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.timeRemaining import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.formatDateTime import com.github.damontecres.wholphin.util.seasonEpisode @@ -77,22 +77,6 @@ import org.jellyfin.sdk.model.extensions.ticks import java.util.UUID import kotlin.time.Duration -data class HomeRow( - @param:StringRes val titleRes: Int?, - val title: String?, - val items: List, -) { - constructor( - @StringRes titleRes: Int, - items: List, - ) : this(titleRes, null, items) - - constructor( - title: String, - items: List, - ) : this(null, title, items) -} - @Composable fun HomePage( preferences: UserPreferences, @@ -203,7 +187,7 @@ fun HomePage( @Composable fun HomePageContent( - homeRows: List, + homeRows: List, onClickItem: (BaseItem) -> Unit, onLongClickItem: (BaseItem) -> Unit, showClock: Boolean, @@ -215,7 +199,10 @@ fun HomePageContent( var position by rememberSaveable(stateSaver = RowColumnSaver) { mutableStateOf(RowColumn(0, 0)) } - var focusedItem = position.let { homeRows.getOrNull(it.row)?.items?.getOrNull(it.column) } + var focusedItem = + position.let { + (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) + } val listState = rememberLazyListState() val focusRequester = remember { FocusRequester() } @@ -284,55 +271,101 @@ fun HomePageContent( modifier = Modifier, ) { itemsIndexed(homeRows) { rowIndex, row -> - if (row.items.isNotEmpty()) { - ItemRow( - title = row.title ?: row.titleRes?.let { stringResource(it) } ?: "", - items = row.items, - onClickItem = onClickItem, - cardOnFocus = { isFocused, index -> - if (isFocused) { - focusedItem = row.items.getOrNull(index) - position = RowColumn(rowIndex, index) - } - }, - onLongClickItem = onLongClickItem, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, cardModifier, onClick, onLongClick -> - // TODO better aspect ration handling? - BannerCard( - name = item?.data?.seriesName ?: item?.name, - imageUrl = item?.imageUrl, - aspectRatio = (2f / 3f), - cornerText = - item?.data?.indexNumber?.let { "E$it" } - ?: item?.data?.childCount?.let { if (it > 0) it.toString() else null }, - played = item?.data?.userData?.played ?: false, - playPercent = item?.data?.userData?.playedPercentage ?: 0.0, - onClick = onClick, - onLongClick = onLongClick, - modifier = - cardModifier - .ifElse( - focusedItem == item, - Modifier.focusRequester(focusRequester), - ).ifElse( - RowColumn(rowIndex, index) == position, - Modifier.focusRequester(positionFocusRequester), - ).onFocusChanged { - if (it.isFocused) { - onFocusPosition?.invoke( - RowColumn( - rowIndex, - index, - ), - ) - } - }, - interactionSource = null, - cardHeight = Cards.height2x3, + when (val r = row) { + is HomeRowLoadingState.Loading, + is HomeRowLoadingState.Pending, + -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = r.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, ) - }, - ) + Text( + text = stringResource(R.string.loading), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + + is HomeRowLoadingState.Error -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = r.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = r.localizedMessage, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + + is HomeRowLoadingState.Success -> { + if (row.items.isNotEmpty()) { + ItemRow( + title = row.title, + items = row.items, + onClickItem = onClickItem, + cardOnFocus = { isFocused, index -> + if (isFocused) { + focusedItem = row.items.getOrNull(index) + position = RowColumn(rowIndex, index) + } + }, + onLongClickItem = onLongClickItem, + modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, cardModifier, onClick, onLongClick -> + // TODO better aspect ration handling? + BannerCard( + name = item?.data?.seriesName ?: item?.name, + imageUrl = item?.imageUrl, + aspectRatio = (2f / 3f), + cornerText = + item?.data?.indexNumber?.let { "E$it" } + ?: item?.data?.childCount?.let { if (it > 0) it.toString() else null }, + played = item?.data?.userData?.played ?: false, + playPercent = + item?.data?.userData?.playedPercentage + ?: 0.0, + onClick = onClick, + onLongClick = onLongClick, + modifier = + cardModifier + .ifElse( + focusedItem == item, + Modifier.focusRequester(focusRequester), + ).ifElse( + RowColumn(rowIndex, index) == position, + Modifier.focusRequester( + positionFocusRequester, + ), + ).onFocusChanged { + if (it.isFocused) { + onFocusPosition?.invoke( + RowColumn( + rowIndex, + index, + ), + ) + } + }, + interactionSource = null, + cardHeight = Cards.height2x3, + ) + }, + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 809387be..623a5bb2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.supportItemKinds @@ -50,7 +51,7 @@ class HomeViewModel val navDrawerItemRepository: NavDrawerItemRepository, ) : ViewModel() { val loadingState = MutableLiveData(LoadingState.Pending) - val homeRows = MutableLiveData>() + val homeRows = MutableLiveData>() private lateinit var preferences: UserPreferences @@ -89,25 +90,21 @@ class HomeViewModel buildList { if (resume.isNotEmpty()) { add( - HomeRow( - titleRes = R.string.continue_watching, + HomeRowLoadingState.Success( + title = context.getString(R.string.continue_watching), items = resume, ), ) } if (nextUp.isNotEmpty()) { add( - HomeRow( - titleRes = R.string.next_up, + HomeRowLoadingState.Success( + title = context.getString(R.string.next_up), items = nextUp, ), ) } - latest.forEach { - if (it.items.isNotEmpty()) { - add(it) - } - } + addAll(latest) } withContext(Dispatchers.Main) { this@HomeViewModel.homeRows.value = homeRows @@ -177,7 +174,7 @@ class HomeViewModel user: UserDto, limit: Int, includedIds: List, - ): List { + ): List { val latestMediaIncludes = user.configuration ?.orderedViews @@ -199,38 +196,47 @@ class HomeViewModel } else { view.name?.let { context.getString(R.string.recently_added_in, it) } } ?: context.getString(R.string.recently_added) - val viewId = - if (view.collectionType == CollectionType.LIVETV) { - api.liveTvApi - .getRecordingFolders( - userId = user.id, - ).content.items - .firstOrNull() - ?.id - } else { - view.id - } - viewId?.let { - val request = - GetLatestMediaRequest( - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = viewId, - groupItems = true, - limit = limit, - isPlayed = null, // Server will handle user's preference + try { + val viewId = + if (view.collectionType == CollectionType.LIVETV) { + api.liveTvApi + .getRecordingFolders( + userId = user.id, + ).content.items + .firstOrNull() + ?.id + } else { + view.id + } + viewId?.let { + val request = + GetLatestMediaRequest( + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = viewId, + groupItems = true, + limit = limit, + isPlayed = null, // Server will handle user's preference + ) + val latest = + api.userLibraryApi + .getLatestMedia(request) + .content + .map { BaseItem.from(it, api, true) } + HomeRowLoadingState.Success( + title = title, + items = latest, ) - val latest = - api.userLibraryApi - .getLatestMedia(request) - .content - .map { BaseItem.from(it, api, true) } - HomeRow( + } + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching %s", title) + HomeRowLoadingState.Error( title = title, - items = latest, + exception = ex, ) } } + return rows } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index 0e41469d..9bf07d62 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -42,3 +42,29 @@ sealed interface RowLoadingState { listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") } } + +sealed interface HomeRowLoadingState { + val title: String + + data class Pending( + override val title: String, + ) : HomeRowLoadingState + + data class Loading( + override val title: String, + ) : HomeRowLoadingState + + data class Success( + override val title: String, + val items: List, + ) : HomeRowLoadingState + + data class Error( + override val title: String, + val message: String? = null, + val exception: Throwable? = null, + ) : HomeRowLoadingState { + val localizedMessage: String = + listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") + } +}