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 val context = LocalContext.current
var firstLoad by rememberSaveable { mutableStateOf(true) } var firstLoad by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init(preferences).join() viewModel.init(firstLoad, preferences).join()
firstLoad = false firstLoad = false
} }
val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) 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) { LaunchedEffect(loading) {
val state = loading val state = loading
if (!firstLoad && state is LoadingState.Error) { if (!firstLoad && state is LoadingState.Error) {
@ -118,7 +119,7 @@ fun HomePage(
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
HomePageContent( HomePageContent(
homeRows, watchingRows + latestRows,
onClickItem = { onClickItem = {
viewModel.navigationManager.navigateTo(it.destination()) viewModel.navigationManager.navigateTo(it.destination())
}, },
@ -277,7 +278,7 @@ fun HomePageContent(
-> { -> {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier = Modifier.animateItem(),
) { ) {
Text( Text(
text = r.title, text = r.title,
@ -295,7 +296,7 @@ fun HomePageContent(
is HomeRowLoadingState.Error -> { is HomeRowLoadingState.Error -> {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier = Modifier.animateItem(),
) { ) {
Text( Text(
text = r.title, text = r.title,
@ -323,7 +324,10 @@ fun HomePageContent(
} }
}, },
onLongClickItem = onLongClickItem, onLongClickItem = onLongClickItem,
modifier = Modifier.fillMaxWidth(), modifier =
Modifier
.fillMaxWidth()
.animateItem(),
cardContent = { index, item, cardModifier, onClick, onLongClick -> cardContent = { index, item, cardModifier, onClick, onLongClick ->
// TODO better aspect ration handling? // TODO better aspect ration handling?
BannerCard( 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.SlimItemFields
import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem 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.ExceptionHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
@ -51,12 +52,18 @@ class HomeViewModel
val navDrawerItemRepository: NavDrawerItemRepository, val navDrawerItemRepository: NavDrawerItemRepository,
) : ViewModel() { ) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending) 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 private lateinit var preferences: UserPreferences
fun init(preferences: UserPreferences): Job { fun init(
loadingState.value = LoadingState.Loading firstLoad: Boolean,
preferences: UserPreferences,
): Job {
if (firstLoad) {
loadingState.value = LoadingState.Loading
}
this.preferences = preferences this.preferences = preferences
val prefs = preferences.appPreferences.homePagePreferences val prefs = preferences.appPreferences.homePagePreferences
val limit = prefs.maxItemsPerRow val limit = prefs.maxItemsPerRow
@ -84,9 +91,7 @@ class HomeViewModel
prefs.enableRewatchingNextUp, prefs.enableRewatchingNextUp,
prefs.combineContinueNext, prefs.combineContinueNext,
) )
val latest = getLatest(userDto, limit, includedIds) val watching =
val homeRows =
buildList { buildList {
if (resume.isNotEmpty()) { if (resume.isNotEmpty()) {
add( 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) { withContext(Dispatchers.Main) {
this@HomeViewModel.homeRows.value = homeRows this@HomeViewModel.watchingRows.value = watching
if (firstLoad) {
this@HomeViewModel.latestRows.value = pendingLatest
}
loadingState.value = LoadingState.Success loadingState.value = LoadingState.Success
} }
loadLatest(latest)
} }
} }
} }
@ -140,7 +152,7 @@ class HomeViewModel
.getResumeItems(request) .getResumeItems(request)
.content .content
.items .items
.map { BaseItem.Companion.from(it, api, true) } .map { BaseItem.from(it, api, true) }
return items return items
} }
@ -174,70 +186,71 @@ class HomeViewModel
user: UserDto, user: UserDto,
limit: Int, limit: Int,
includedIds: List<UUID>, includedIds: List<UUID>,
): List<HomeRowLoadingState> { ): List<LatestData> {
val latestMediaIncludes = val excluded = user.configuration?.latestItemsExcludes.orEmpty()
user.configuration
?.orderedViews
.orEmpty()
.toMutableList()
.apply {
removeAll(user.configuration?.latestItemsExcludes.orEmpty())
}.filter { includedIds.contains(it) }
val views by api.userViewsApi.getUserViews() val views by api.userViewsApi.getUserViews()
val rows = val latestData =
latestMediaIncludes views.items
.mapNotNull { viewId -> views.items.firstOrNull { it.id == viewId } } .filter {
.filter { it.collectionType in supportedLatestCollectionTypes } it.id in includedIds && it.id !in excluded &&
.mapNotNull { view -> it.collectionType in supportedLatestCollectionTypes
}.mapNotNull { view ->
val title = val title =
if (view.collectionType == CollectionType.LIVETV) { if (view.collectionType == CollectionType.LIVETV) {
context.getString(R.string.recently_recorded) context.getString(R.string.recently_recorded)
} else { } else {
view.name?.let { context.getString(R.string.recently_added_in, it) } view.name?.let { context.getString(R.string.recently_added_in, it) }
} ?: context.getString(R.string.recently_added) } ?: context.getString(R.string.recently_added)
try { val viewId =
val viewId = if (view.collectionType == CollectionType.LIVETV) {
if (view.collectionType == CollectionType.LIVETV) { api.liveTvApi
api.liveTvApi .getRecordingFolders(
.getRecordingFolders( userId = user.id,
userId = user.id, ).content.items
).content.items .firstOrNull()
.firstOrNull() ?.id
?.id } else {
} else { view.id
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,
)
} }
} catch (ex: Exception) { viewId?.let {
Timber.e(ex, "Exception fetching %s", title) val request =
HomeRowLoadingState.Error( GetLatestMediaRequest(
title = title, fields = SlimItemFields,
exception = ex, 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( fun setWatched(
@ -250,7 +263,7 @@ class HomeViewModel
api.playStateApi.markUnplayedItem(itemId) api.playStateApi.markUnplayedItem(itemId)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
init(preferences) init(false, preferences)
} }
} }
@ -264,7 +277,7 @@ class HomeViewModel
api.userLibraryApi.unmarkFavoriteItem(itemId) api.userLibraryApi.unmarkFavoriteItem(itemId)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
init(preferences) init(false, preferences)
} }
} }
} }
@ -275,3 +288,8 @@ val supportedLatestCollectionTypes =
CollectionType.TVSHOWS, CollectionType.TVSHOWS,
CollectionType.LIVETV, CollectionType.LIVETV,
) )
data class LatestData(
val title: String,
val request: GetLatestMediaRequest,
)

View file

@ -148,6 +148,20 @@ fun DestinationContent(
BaseItemKind.USER_VIEW -> BaseItemKind.USER_VIEW ->
when (destination.item?.data?.collectionType) { when (destination.item?.data?.collectionType) {
CollectionType.TVSHOWS ->
CollectionFolderTv(
preferences,
destination,
modifier,
)
CollectionType.MOVIES ->
CollectionFolderMovie(
preferences,
destination,
modifier,
)
CollectionType.LIVETV -> CollectionType.LIVETV ->
CollectionFolderLiveTv( CollectionFolderLiveTv(
preferences, preferences,