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 package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier 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.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn 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.HomePageContent
import com.github.damontecres.wholphin.ui.main.HomeRow
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient 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.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject
@HiltViewModel @HiltViewModel(assistedFactory = RecommendedMovieViewModel.Factory::class)
class RecommendedMovieViewModel class RecommendedMovieViewModel
@Inject @AssistedInject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
@Assisted val parentId: UUID,
) : ViewModel() { ) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) @AssistedFactory
val rows = MutableLiveData<List<HomeRow>>() interface Factory {
fun create(parentId: UUID): RecommendedMovieViewModel
}
fun init( val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
preferences: UserPreferences,
parentId: UUID, val rows =
) { MutableStateFlow<MutableList<HomeRowLoadingState>>(
rowTitles
.map {
HomeRowLoadingState.Pending(
context.getString(it),
)
}.toMutableList(),
)
fun init() {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val resumeItemsRequest = try {
GetResumeItemsRequest( val resumeItemsRequest =
parentId = parentId, GetResumeItemsRequest(
fields = SlimItemFields, parentId = parentId,
includeItemTypes = listOf(BaseItemKind.MOVIE), fields = SlimItemFields,
enableUserData = true, 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 = val recentlyReleasedRequest =
GetItemsRequest( GetItemsRequest(
@ -109,22 +158,50 @@ class RecommendedMovieViewModel
val unwatchedTopRatedItems = val unwatchedTopRatedItems =
ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true)
val rows = listOf(resumeItems, recentlyReleasedItems, recentlyAddedItems, suggestedItems, unwatchedTopRatedItems) val rows =
rows.forEach { it.init() }
val homeRows =
listOf( listOf(
HomeRow(R.string.continue_watching, resumeItems), R.string.recently_released to recentlyReleasedItems,
HomeRow(R.string.recently_released, recentlyReleasedItems), R.string.recently_added to recentlyAddedItems,
HomeRow(R.string.recently_added, recentlyAddedItems), R.string.suggestions to suggestedItems,
HomeRow(R.string.suggestions, suggestedItems), R.string.top_unwatched to unwatchedTopRatedItems,
HomeRow(R.string.top_unwatched, unwatchedTopRatedItems), )
).filter { it.items.isNotEmpty() }
withContext(Dispatchers.Main) { rows.forEachIndexed { index, (title, pager) ->
this@RecommendedMovieViewModel.rows.value = homeRows viewModelScope.launchIO {
loading.value = LoadingState.Success 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, onClickItem: (BaseItem) -> Unit,
onFocusPosition: (RowColumn) -> Unit, onFocusPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: RecommendedMovieViewModel = hiltViewModel(), viewModel: RecommendedMovieViewModel =
hiltViewModel<RecommendedMovieViewModel, RecommendedMovieViewModel.Factory>(
creationCallback = { it.create(parentId) },
),
) { ) {
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
viewModel.init(preferences, parentId) viewModel.init()
} }
val loading by viewModel.loading.observeAsState(LoadingState.Loading) 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) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)

View file

@ -1,6 +1,8 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier 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.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn 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.HomePageContent
import com.github.damontecres.wholphin.ui.main.HomeRow
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetNextUpRequestHandler import com.github.damontecres.wholphin.util.GetNextUpRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient 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.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject
@HiltViewModel @HiltViewModel(assistedFactory = RecommendedTvShowViewModel.Factory::class)
class RecommendedTvShowViewModel class RecommendedTvShowViewModel
@Inject @AssistedInject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
@Assisted val parentId: UUID,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(parentId: UUID): RecommendedTvShowViewModel
}
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val rows = MutableLiveData<List<HomeRow>>()
fun init( val rows =
preferences: UserPreferences, MutableStateFlow<MutableList<HomeRowLoadingState>>(
parentId: UUID, rowTitles
) { .map {
HomeRowLoadingState.Pending(
context.getString(it),
)
}.toMutableList(),
)
fun init() {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val resumeItemsRequest = try {
GetResumeItemsRequest( val resumeItemsRequest =
parentId = parentId, GetResumeItemsRequest(
fields = SlimItemFields, parentId = parentId,
includeItemTypes = listOf(BaseItemKind.EPISODE), fields = SlimItemFields,
enableUserData = true, includeItemTypes = listOf(BaseItemKind.EPISODE),
) enableUserData = true,
val resumeItems = )
ApiRequestPager(api, resumeItemsRequest, GetResumeItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) val resumeItems =
ApiRequestPager(
api,
resumeItemsRequest,
GetResumeItemsRequestHandler,
viewModelScope,
useSeriesForPrimary = true,
).init()
if (resumeItems.isNotEmpty()) {
resumeItems.getBlocking(0)
}
val nextUpRequest = val nextUpRequest =
GetNextUpRequest( GetNextUpRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
enableUserData = true, 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 = val recentlyReleasedRequest =
GetItemsRequest( GetItemsRequest(
@ -120,23 +187,50 @@ class RecommendedTvShowViewModel
ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true)
val rows = val rows =
listOf(resumeItems, nextUpItems, recentlyReleasedItems, recentlyAddedItems, suggestedItems, unwatchedTopRatedItems)
rows.forEach { it.init() }
val homeRows =
listOf( listOf(
HomeRow(R.string.continue_watching, resumeItems), R.string.recently_released to recentlyReleasedItems,
HomeRow(R.string.next_up, nextUpItems), R.string.recently_added to recentlyAddedItems,
HomeRow(R.string.recently_released, recentlyReleasedItems), R.string.suggestions to suggestedItems,
HomeRow(R.string.recently_added, recentlyAddedItems), R.string.top_unwatched to unwatchedTopRatedItems,
HomeRow(R.string.suggestions, suggestedItems), )
HomeRow(R.string.top_unwatched, unwatchedTopRatedItems),
).filter { it.items.isNotEmpty() } rows.forEachIndexed { index, (title, pager) ->
withContext(Dispatchers.Main) { viewModelScope.launchIO {
this@RecommendedTvShowViewModel.rows.value = homeRows val title = context.getString(title)
loading.value = LoadingState.Success 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, onClickItem: (BaseItem) -> Unit,
onFocusPosition: (RowColumn) -> Unit, onFocusPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: RecommendedTvShowViewModel = hiltViewModel(), viewModel: RecommendedTvShowViewModel =
hiltViewModel<RecommendedTvShowViewModel, RecommendedTvShowViewModel.Factory>(
creationCallback = { it.create(parentId) },
),
) { ) {
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
viewModel.init(preferences, parentId) viewModel.init()
} }
val loading by viewModel.loading.observeAsState(LoadingState.Loading) 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) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)

View file

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

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.ui.main package com.github.damontecres.wholphin.ui.main
import android.widget.Toast import android.widget.Toast
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.roundMinutes
import com.github.damontecres.wholphin.ui.timeRemaining import com.github.damontecres.wholphin.ui.timeRemaining
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.LoadingState
import com.github.damontecres.wholphin.util.formatDateTime import com.github.damontecres.wholphin.util.formatDateTime
import com.github.damontecres.wholphin.util.seasonEpisode import com.github.damontecres.wholphin.util.seasonEpisode
@ -77,22 +77,6 @@ import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID import java.util.UUID
import kotlin.time.Duration 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 @Composable
fun HomePage( fun HomePage(
preferences: UserPreferences, preferences: UserPreferences,
@ -203,7 +187,7 @@ fun HomePage(
@Composable @Composable
fun HomePageContent( fun HomePageContent(
homeRows: List<HomeRow>, homeRows: List<HomeRowLoadingState>,
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit, onLongClickItem: (BaseItem) -> Unit,
showClock: Boolean, showClock: Boolean,
@ -215,7 +199,10 @@ fun HomePageContent(
var position by rememberSaveable(stateSaver = RowColumnSaver) { var position by rememberSaveable(stateSaver = RowColumnSaver) {
mutableStateOf(RowColumn(0, 0)) 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 listState = rememberLazyListState()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -284,55 +271,101 @@ fun HomePageContent(
modifier = Modifier, modifier = Modifier,
) { ) {
itemsIndexed(homeRows) { rowIndex, row -> itemsIndexed(homeRows) { rowIndex, row ->
if (row.items.isNotEmpty()) { when (val r = row) {
ItemRow( is HomeRowLoadingState.Loading,
title = row.title ?: row.titleRes?.let { stringResource(it) } ?: "", is HomeRowLoadingState.Pending,
items = row.items, -> {
onClickItem = onClickItem, Column(
cardOnFocus = { isFocused, index -> verticalArrangement = Arrangement.spacedBy(8.dp),
if (isFocused) { modifier = modifier,
focusedItem = row.items.getOrNull(index) ) {
position = RowColumn(rowIndex, index) Text(
} text = r.title,
}, style = MaterialTheme.typography.titleLarge,
onLongClickItem = onLongClickItem, color = MaterialTheme.colorScheme.onBackground,
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,
) )
}, 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.NavigationManager
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
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.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.supportItemKinds import com.github.damontecres.wholphin.util.supportItemKinds
@ -50,7 +51,7 @@ 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<HomeRow>>() val homeRows = MutableLiveData<List<HomeRowLoadingState>>()
private lateinit var preferences: UserPreferences private lateinit var preferences: UserPreferences
@ -89,25 +90,21 @@ class HomeViewModel
buildList { buildList {
if (resume.isNotEmpty()) { if (resume.isNotEmpty()) {
add( add(
HomeRow( HomeRowLoadingState.Success(
titleRes = R.string.continue_watching, title = context.getString(R.string.continue_watching),
items = resume, items = resume,
), ),
) )
} }
if (nextUp.isNotEmpty()) { if (nextUp.isNotEmpty()) {
add( add(
HomeRow( HomeRowLoadingState.Success(
titleRes = R.string.next_up, title = context.getString(R.string.next_up),
items = nextUp, items = nextUp,
), ),
) )
} }
latest.forEach { addAll(latest)
if (it.items.isNotEmpty()) {
add(it)
}
}
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@HomeViewModel.homeRows.value = homeRows this@HomeViewModel.homeRows.value = homeRows
@ -177,7 +174,7 @@ class HomeViewModel
user: UserDto, user: UserDto,
limit: Int, limit: Int,
includedIds: List<UUID>, includedIds: List<UUID>,
): List<HomeRow> { ): List<HomeRowLoadingState> {
val latestMediaIncludes = val latestMediaIncludes =
user.configuration user.configuration
?.orderedViews ?.orderedViews
@ -199,38 +196,47 @@ class HomeViewModel
} 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)
val viewId = try {
if (view.collectionType == CollectionType.LIVETV) { val viewId =
api.liveTvApi if (view.collectionType == CollectionType.LIVETV) {
.getRecordingFolders( api.liveTvApi
userId = user.id, .getRecordingFolders(
).content.items userId = user.id,
.firstOrNull() ).content.items
?.id .firstOrNull()
} else { ?.id
view.id } else {
} view.id
viewId?.let { }
val request = viewId?.let {
GetLatestMediaRequest( val request =
fields = SlimItemFields, GetLatestMediaRequest(
imageTypeLimit = 1, fields = SlimItemFields,
parentId = viewId, imageTypeLimit = 1,
groupItems = true, parentId = viewId,
limit = limit, groupItems = true,
isPlayed = null, // Server will handle user's preference 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 } catch (ex: Exception) {
.getLatestMedia(request) Timber.e(ex, "Exception fetching %s", title)
.content HomeRowLoadingState.Error(
.map { BaseItem.from(it, api, true) }
HomeRow(
title = title, title = title,
items = latest, exception = ex,
) )
} }
} }
return rows return rows
} }

View file

@ -42,3 +42,29 @@ sealed interface RowLoadingState {
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") 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(" - ")
}
}