From ce8cab0d1fe0836dcc7801f3301756396efe8eb9 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Mon, 10 Nov 2025 13:18:36 -0500 Subject: [PATCH] Home page improvements (#190) Separates the loading logic into two groups: 1. Continue Watching & Next Up 2. Latest media The home page displays once the first group is ready and shows placeholders while the latest media is fetched. This also speeds up refreshes when returning home after watching an episode. Also ensure grouped Movie/TV libraries are displayed as Movie/TV libraries instead of falling back to a generic grid display. Fixes #189 --- .../damontecres/wholphin/ui/main/HomePage.kt | 16 +- .../wholphin/ui/main/HomeViewModel.kt | 146 ++++++++++-------- .../wholphin/ui/nav/DestinationContent.kt | 14 ++ 3 files changed, 106 insertions(+), 70 deletions(-) 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 9a189973..812c3343 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 @@ -87,11 +87,12 @@ fun HomePage( val context = LocalContext.current var firstLoad by rememberSaveable { mutableStateOf(true) } LaunchedEffect(Unit) { - viewModel.init(preferences).join() + viewModel.init(firstLoad, preferences).join() firstLoad = false } val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) - val homeRows by viewModel.homeRows.observeAsState(listOf()) + val watchingRows by viewModel.watchingRows.observeAsState(listOf()) + val latestRows by viewModel.latestRows.observeAsState(listOf()) LaunchedEffect(loading) { val state = loading if (!firstLoad && state is LoadingState.Error) { @@ -118,7 +119,7 @@ fun HomePage( var showPlaylistDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) HomePageContent( - homeRows, + watchingRows + latestRows, onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) }, @@ -277,7 +278,7 @@ fun HomePageContent( -> { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, + modifier = Modifier.animateItem(), ) { Text( text = r.title, @@ -295,7 +296,7 @@ fun HomePageContent( is HomeRowLoadingState.Error -> { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, + modifier = Modifier.animateItem(), ) { Text( text = r.title, @@ -323,7 +324,10 @@ fun HomePageContent( } }, onLongClickItem = onLongClickItem, - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .animateItem(), cardContent = { index, item, cardModifier, onClick, onLongClick -> // TODO better aspect ration handling? BannerCard( 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 623a5bb2..cf8668a6 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 @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences 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.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingExceptionHandler @@ -51,12 +52,18 @@ class HomeViewModel val navDrawerItemRepository: NavDrawerItemRepository, ) : ViewModel() { val loadingState = MutableLiveData(LoadingState.Pending) - val homeRows = MutableLiveData>() + val watchingRows = MutableLiveData>(listOf()) + val latestRows = MutableLiveData>(listOf()) private lateinit var preferences: UserPreferences - fun init(preferences: UserPreferences): Job { - loadingState.value = LoadingState.Loading + fun init( + firstLoad: Boolean, + preferences: UserPreferences, + ): Job { + if (firstLoad) { + loadingState.value = LoadingState.Loading + } this.preferences = preferences val prefs = preferences.appPreferences.homePagePreferences val limit = prefs.maxItemsPerRow @@ -84,9 +91,7 @@ class HomeViewModel prefs.enableRewatchingNextUp, prefs.combineContinueNext, ) - val latest = getLatest(userDto, limit, includedIds) - - val homeRows = + val watching = buildList { if (resume.isNotEmpty()) { add( @@ -104,12 +109,19 @@ class HomeViewModel ), ) } - addAll(latest) } + + val latest = getLatest(userDto, limit, includedIds) + val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } + withContext(Dispatchers.Main) { - this@HomeViewModel.homeRows.value = homeRows + this@HomeViewModel.watchingRows.value = watching + if (firstLoad) { + this@HomeViewModel.latestRows.value = pendingLatest + } loadingState.value = LoadingState.Success } + loadLatest(latest) } } } @@ -140,7 +152,7 @@ class HomeViewModel .getResumeItems(request) .content .items - .map { BaseItem.Companion.from(it, api, true) } + .map { BaseItem.from(it, api, true) } return items } @@ -174,70 +186,71 @@ class HomeViewModel user: UserDto, limit: Int, includedIds: List, - ): List { - val latestMediaIncludes = - user.configuration - ?.orderedViews - .orEmpty() - .toMutableList() - .apply { - removeAll(user.configuration?.latestItemsExcludes.orEmpty()) - }.filter { includedIds.contains(it) } - + ): List { + val excluded = user.configuration?.latestItemsExcludes.orEmpty() val views by api.userViewsApi.getUserViews() - val rows = - latestMediaIncludes - .mapNotNull { viewId -> views.items.firstOrNull { it.id == viewId } } - .filter { it.collectionType in supportedLatestCollectionTypes } - .mapNotNull { view -> + val latestData = + views.items + .filter { + it.id in includedIds && it.id !in excluded && + it.collectionType in supportedLatestCollectionTypes + }.mapNotNull { view -> val title = if (view.collectionType == CollectionType.LIVETV) { context.getString(R.string.recently_recorded) } else { view.name?.let { context.getString(R.string.recently_added_in, it) } } ?: context.getString(R.string.recently_added) - 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 viewId = + if (view.collectionType == CollectionType.LIVETV) { + api.liveTvApi + .getRecordingFolders( + userId = user.id, + ).content.items + .firstOrNull() + ?.id + } else { + view.id } - } catch (ex: Exception) { - Timber.e(ex, "Exception fetching %s", title) - HomeRowLoadingState.Error( - title = title, - exception = ex, - ) + viewId?.let { + val request = + GetLatestMediaRequest( + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = viewId, + groupItems = true, + limit = limit, + isPlayed = null, // Server will handle user's preference + ) + LatestData(title, request) } } - return rows + return latestData + } + + private suspend fun loadLatest(latestData: List) { + val rows = + latestData.map { (title, request) -> + try { + val latest = + api.userLibraryApi + .getLatestMedia(request) + .content + .map { BaseItem.from(it, api, true) } + HomeRowLoadingState.Success( + title = title, + items = latest, + ) + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching %s", title) + HomeRowLoadingState.Error( + title = title, + exception = ex, + ) + } + } + latestRows.setValueOnMain(rows) } fun setWatched( @@ -250,7 +263,7 @@ class HomeViewModel api.playStateApi.markUnplayedItem(itemId) } withContext(Dispatchers.Main) { - init(preferences) + init(false, preferences) } } @@ -264,7 +277,7 @@ class HomeViewModel api.userLibraryApi.unmarkFavoriteItem(itemId) } withContext(Dispatchers.Main) { - init(preferences) + init(false, preferences) } } } @@ -275,3 +288,8 @@ val supportedLatestCollectionTypes = CollectionType.TVSHOWS, CollectionType.LIVETV, ) + +data class LatestData( + val title: String, + val request: GetLatestMediaRequest, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 72d3fdd0..099f4816 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -148,6 +148,20 @@ fun DestinationContent( BaseItemKind.USER_VIEW -> when (destination.item?.data?.collectionType) { + CollectionType.TVSHOWS -> + CollectionFolderTv( + preferences, + destination, + modifier, + ) + + CollectionType.MOVIES -> + CollectionFolderMovie( + preferences, + destination, + modifier, + ) + CollectionType.LIVETV -> CollectionFolderLiveTv( preferences,