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
This commit is contained in:
damontecres 2025-11-10 13:18:36 -05:00 committed by GitHub
parent 98fc996bd5
commit ce8cab0d1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 106 additions and 70 deletions

View file

@ -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<UUID?>(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(

View file

@ -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>(LoadingState.Pending)
val homeRows = MutableLiveData<List<HomeRowLoadingState>>()
val watchingRows = MutableLiveData<List<HomeRowLoadingState>>(listOf())
val latestRows = MutableLiveData<List<HomeRowLoadingState>>(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<UUID>,
): List<HomeRowLoadingState> {
val latestMediaIncludes =
user.configuration
?.orderedViews
.orEmpty()
.toMutableList()
.apply {
removeAll(user.configuration?.latestItemsExcludes.orEmpty())
}.filter { includedIds.contains(it) }
): List<LatestData> {
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<LatestData>) {
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,
)

View file

@ -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,