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
This commit is contained in:
damontecres 2025-11-09 17:40:47 -05:00 committed by GitHub
parent 0fb1a69556
commit 98fc996bd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 422 additions and 179 deletions

View file

@ -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>(LoadingState.Loading)
val rows = MutableLiveData<List<HomeRow>>()
@AssistedFactory
interface Factory {
fun create(parentId: UUID): RecommendedMovieViewModel
}
fun init(
preferences: UserPreferences,
parentId: UUID,
) {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val rows =
MutableStateFlow<MutableList<HomeRowLoadingState>>(
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<RecommendedMovieViewModel, RecommendedMovieViewModel.Factory>(
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)

View file

@ -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>(LoadingState.Loading)
val rows = MutableLiveData<List<HomeRow>>()
fun init(
preferences: UserPreferences,
parentId: UUID,
) {
val rows =
MutableStateFlow<MutableList<HomeRowLoadingState>>(
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<RecommendedTvShowViewModel, RecommendedTvShowViewModel.Factory>(
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)

View file

@ -59,7 +59,7 @@ class MovieViewModel
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(itemId2: UUID): MovieViewModel
fun create(itemId: UUID): MovieViewModel
}
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)

View file

@ -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<BaseItem?>,
) {
constructor(
@StringRes titleRes: Int,
items: List<BaseItem?>,
) : this(titleRes, null, items)
constructor(
title: String,
items: List<BaseItem?>,
) : this(null, title, items)
}
@Composable
fun HomePage(
preferences: UserPreferences,
@ -203,7 +187,7 @@ fun HomePage(
@Composable
fun HomePageContent(
homeRows: List<HomeRow>,
homeRows: List<HomeRowLoadingState>,
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,
)
},
)
}
}
}
}
}

View file

@ -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>(LoadingState.Pending)
val homeRows = MutableLiveData<List<HomeRow>>()
val homeRows = MutableLiveData<List<HomeRowLoadingState>>()
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<UUID>,
): List<HomeRow> {
): List<HomeRowLoadingState> {
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
}

View file

@ -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<BaseItem?>,
) : 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(" - ")
}
}