diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index e56747a0..df1f80bb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -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.Loading) - val rows = MutableLiveData>() + @AssistedFactory + interface Factory { + fun create(parentId: UUID): RecommendedMovieViewModel + } - fun init( - preferences: UserPreferences, - parentId: UUID, - ) { + val loading = MutableLiveData(LoadingState.Loading) + + val rows = + MutableStateFlow>( + 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( + 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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index 98b03cb4..1ce8cff2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -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.Loading) - val rows = MutableLiveData>() - fun init( - preferences: UserPreferences, - parentId: UUID, - ) { + val rows = + MutableStateFlow>( + 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( + 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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 8f7c2cf8..540affd8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -59,7 +59,7 @@ class MovieViewModel ) : ViewModel() { @AssistedFactory interface Factory { - fun create(itemId2: UUID): MovieViewModel + fun create(itemId: UUID): MovieViewModel } val loading = MutableLiveData(LoadingState.Pending) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index 78775e03..77d7e386 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -24,18 +24,18 @@ import org.jellyfin.sdk.model.extensions.ticks @Composable fun FocusedEpisodeHeader( - ep: BaseItem, + ep: BaseItem?, overviewOnClick: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current - val dto = ep.data + val dto = ep?.data Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { Text( - text = dto.episodeTitle ?: dto.name ?: "", + text = dto?.episodeTitle ?: dto?.name ?: "", style = MaterialTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -46,17 +46,17 @@ fun FocusedEpisodeHeader( ) { val details = buildList { - dto.seasonEpisode?.let(::add) - dto.premiereDate?.let { add(formatDateTime(it)) } - val duration = dto.runTimeTicks?.ticks + dto?.seasonEpisode?.let(::add) + dto?.premiereDate?.let { add(formatDateTime(it)) } + val duration = dto?.runTimeTicks?.ticks duration ?.roundMinutes ?.toString() ?.let { add(it) } - dto.officialRating?.let(::add) - dto.timeRemaining?.roundMinutes?.let { add("$it left") } + dto?.officialRating?.let(::add) + dto?.timeRemaining?.roundMinutes?.let { add("$it left") } } DotSeparatedRow( texts = details, @@ -68,12 +68,12 @@ fun FocusedEpisodeHeader( verticalAlignment = Alignment.CenterVertically, ) { SimpleStarRating( - dto.communityRating, + dto?.communityRating, Modifier.height(20.dp), ) } OverviewText( - overview = dto.overview ?: "", + overview = dto?.overview ?: "", maxLines = 3, onClick = overviewOnClick, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index f2fde9f3..ce73e872 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -162,13 +162,11 @@ fun SeriesOverviewContent( } item { // Episode header - focusedEpisode?.let { ep -> - FocusedEpisodeHeader( - ep = ep, - overviewOnClick = overviewOnClick, - modifier = Modifier.fillMaxWidth(.66f), - ) - } + FocusedEpisodeHeader( + ep = focusedEpisode, + overviewOnClick = overviewOnClick, + modifier = Modifier.fillMaxWidth(.66f), + ) } item { key(position.seasonTabIndex) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 8acb295e..36b8b124 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -293,17 +293,11 @@ class SeriesViewModel itemId: UUID, listIndex: Int, ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - val base = api.userLibraryApi.getItem(itemId).content - val item = BaseItem.Companion.from(base, api) val eps = episodes.value if (eps is EpisodeList.Success) { - val newItems = - eps.episodes.toMutableList().apply { - this[listIndex] = item - } - val newValue = eps.copy(episodes = newItems) + eps.episodes.refreshItem(listIndex, itemId) withContext(Dispatchers.Main) { - episodes.value = newValue + episodes.value = eps } } } @@ -409,7 +403,7 @@ sealed interface EpisodeList { } data class Success( - val episodes: List, + val episodes: ApiRequestPager, val initialIndex: Int, ) : EpisodeList } 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 7e424cb6..9a189973 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 @@ -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, -) { - constructor( - @StringRes titleRes: Int, - items: List, - ) : this(titleRes, null, items) - - constructor( - title: String, - items: List, - ) : this(null, title, items) -} - @Composable fun HomePage( preferences: UserPreferences, @@ -203,7 +187,7 @@ fun HomePage( @Composable fun HomePageContent( - homeRows: List, + homeRows: List, 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, + ) + }, + ) + } + } } } } 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 809387be..623a5bb2 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 @@ -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.Pending) - val homeRows = MutableLiveData>() + val homeRows = MutableLiveData>() 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, - ): List { + ): List { 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 } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index bde82618..2cc5bb3d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -21,6 +21,7 @@ import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.playlistsApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.GetProgramsDto import org.jellyfin.sdk.model.api.request.GetEpisodesRequest @@ -31,6 +32,7 @@ import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest 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 java.util.function.Predicate /** @@ -58,7 +60,7 @@ class ApiRequestPager( CacheBuilder .newBuilder() .maximumSize(cacheSize) - .build>() + .build>() suspend fun init(): ApiRequestPager { if (totalCount < 0) { @@ -122,13 +124,38 @@ class ApiRequestPager( false, ) val result = requestHandler.execute(api, newRequest).content - val data = result.items.map { BaseItem.from(it, api, useSeriesForPrimary) } + val data = mutableListOf() + result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) } cachedPages.put(pageNumber, data) items = ItemList(totalCount, pageSize, cachedPages.asMap()) } } } + suspend fun refreshItem( + position: Int, + itemId: UUID, + ) { + val item = + api.userLibraryApi.getItem(itemId).content.let { + BaseItem.from( + it, + api, + useSeriesForPrimary, + ) + } + val pageNumber = position / pageSize + val index = position - pageNumber * pageSize + mutex.withLock { + val page = cachedPages.getIfPresent(pageNumber) + if (page != null && index in page.indices) { + page[index] = item + cachedPages.put(pageNumber, page) + items = ItemList(totalCount, pageSize, cachedPages.asMap()) + } + } + } + companion object { private const val DEBUG = false } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index 0e41469d..9bf07d62 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -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, + ) : 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(" - ") + } +}