From f4bdb8446eed34a8fe0539354c23e89145aca67b Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 16 May 2026 11:35:11 -0400 Subject: [PATCH] View more from home page & recommended rows (#1392) ## Description Adds a card at the end of the rows on the home page and recommended tabs to "View More" which goes to a grid of the items in the row. The grid will endlessly scroll all of the available items except for a few cases where the first 1000 are shown because the server does not (yet) support pagination: genres, studios, people (added in v12), and latest grouped tv shows. Dev notes: This PR also rewrites the `RecommendedViewModel` and consolidates the previous multiple view models into one. ### Related issues Closes #1371 Closes #1035 Similar to #1198 ### Testing Emulator ## Screenshots N/A, see #1198 ## AI or LLM usage None --- .../damontecres/wholphin/data/ExtrasItem.kt | 25 +- .../wholphin/services/HomeSettingsService.kt | 223 ++++++---- .../wholphin/services/LatestNextUpService.kt | 6 +- .../wholphin/ui/cards/DiscoverItemCard.kt | 91 ---- .../damontecres/wholphin/ui/cards/ItemRow.kt | 15 + .../wholphin/ui/cards/ViewMoreCard.kt | 149 +++++++ .../wholphin/ui/components/GenreCardGrid.kt | 3 +- .../wholphin/ui/components/ItemGrid.kt | 37 +- .../ui/components/RecommendedContent.kt | 394 ++++++++++++----- .../ui/components/RecommendedMovie.kt | 325 ++++---------- .../ui/components/RecommendedMusic.kt | 282 +++--------- .../ui/components/RecommendedTvShow.kt | 388 ++++------------- .../wholphin/ui/components/StudioCardGrid.kt | 3 +- .../wholphin/ui/detail/CardGrid.kt | 2 +- .../wholphin/ui/detail/HomeRowGrid.kt | 404 ++++++++++++++++++ .../wholphin/ui/discover/DiscoverRow.kt | 4 +- .../damontecres/wholphin/ui/main/HomePage.kt | 68 +++ .../ui/main/settings/HomeSettingsPage.kt | 1 + .../wholphin/ui/nav/Destination.kt | 18 +- .../wholphin/ui/nav/DestinationContent.kt | 12 +- .../wholphin/util/ApiRequestPager.kt | 42 ++ 21 files changed, 1417 insertions(+), 1075 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt index ce73e6eb..c81afacc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt @@ -4,9 +4,15 @@ import androidx.annotation.PluralsRes import androidx.annotation.StringRes import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.util.GetItemsRequestHandler import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.ExtraType +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest /** * Represents "extras" for media such as behind-the-scenes or deleted scenes @@ -34,7 +40,24 @@ sealed interface ExtrasItem { override val isPlayed: Boolean, ) : ExtrasItem { override val destination: Destination = - Destination.ItemGrid(null, type.stringRes, items.map { it.id }) + Destination.ItemGrid( + title = null, + titleRes = type.stringRes, + request = + GetItemsRequest( + ids = items.map { it.id }, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ), + requestHandler = GetItemsRequestHandler, + initialPosition = 0, + viewOptions = + ViewOptions( + columns = 3, + spacing = 24, + aspectRatio = AspectRatio.WIDE, + ), + ) override val playedPercentage get() = -1.0 diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 784440aa..5162d086 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -19,9 +19,12 @@ import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions import com.github.damontecres.wholphin.ui.playback.getTypeFor import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler import com.github.damontecres.wholphin.util.GetStudiosRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error @@ -56,6 +59,7 @@ import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest import org.jellyfin.sdk.model.api.request.GetRecordingsRequest @@ -572,6 +576,7 @@ class HomeSettingsService libraries: List, limit: Int = prefs.maxItemsPerRow, isRefresh: Boolean, + usePaging: Boolean = false, ): HomeRowLoadingState = when (row) { is HomeRowConfig.ContinueWatching -> { @@ -631,10 +636,11 @@ class HomeSettingsService Success( title = context.getString(R.string.continue_watching), items = - latestNextUpService.buildCombined( - resume, - nextUp, - ), + latestNextUpService + .buildCombined( + resume, + nextUp, + ).take(limit), viewOptions = row.viewOptions, rowType = row, ) @@ -769,7 +775,7 @@ class HomeSettingsService api.userLibraryApi .getLatestMedia(request) .content - .map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) } + .map { BaseItem(it, row.viewOptions.useSeries) } .let { Success( title, @@ -799,18 +805,27 @@ class HomeSettingsService fields = DefaultItemFields, recursive = true, ) - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) } - .let { - Success( - title, - it, - row.viewOptions, - rowType = row, - ) - } + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem.from(it, api, row.viewOptions.useSeries) } + }.let { + Success( + title, + it, + row.viewOptions, + rowType = row, + ) + } } is HomeRowConfig.ByParent -> { @@ -824,22 +839,29 @@ class HomeSettingsService limit = limit, fields = DefaultItemFields, ) - val name = - api.userLibraryApi - .getItem(itemId = row.parentId) - .content.name - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - .let { - Success( - name ?: context.getString(R.string.collection), - it, - row.viewOptions, - rowType = row, - ) - } + + val name = getItemName(row.parentId) + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + name ?: context.getString(R.string.collection), + it, + row.viewOptions, + rowType = row, + ) + } } is HomeRowConfig.GetItems -> { @@ -856,18 +878,27 @@ class HomeSettingsService ) } } - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - .let { - Success( - row.name, - it, - row.viewOptions, - rowType = row, - ) - } + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + row.name, + it, + row.viewOptions, + rowType = row, + ) + } } is HomeRowConfig.Favorite -> { @@ -907,18 +938,27 @@ class HomeSettingsService includeItemTypes = listOf(row.kind), isFavorite = true, ) - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - .let { - Success( - title, - it, - row.viewOptions, - rowType = row, - ) - } + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + title, + it, + row.viewOptions, + rowType = row, + ) + } } } @@ -931,19 +971,29 @@ class HomeSettingsService limit = limit, enableImages = true, enableUserData = true, + enableTotalRecordCount = false, ) - api.liveTvApi - .getRecordings(request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - .let { - Success( - context.getString(R.string.active_recordings), - it, - row.viewOptions, - rowType = row, - ) - } + if (usePaging) { + ApiRequestPager( + api, + request, + GetRecordingsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + api.liveTvApi + .getRecordings(request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + context.getString(R.string.active_recordings), + it, + row.viewOptions, + rowType = row, + ) + } } is HomeRowConfig.TvPrograms -> { @@ -957,6 +1007,7 @@ class HomeSettingsService enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO), imageTypeLimit = 1, ) + // paging not supported api.liveTvApi .getRecommendedPrograms(request) .content.items @@ -972,21 +1023,33 @@ class HomeSettingsService } is HomeRowConfig.TvChannels -> { - api.liveTvApi - .getLiveTvChannels( + val request = + GetLiveTvChannelsRequest( userId = userDto.id, fields = DefaultItemFields, limit = limit, enableImages = true, - ).toBaseItems(api, row.viewOptions.useSeries) - .let { - Success( - context.getString(R.string.channels), - it, - row.viewOptions, - rowType = row, - ) - } + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetLiveTvChannelsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + api.liveTvApi + .getLiveTvChannels(request) + .toBaseItems(api, row.viewOptions.useSeries) + }.let { + Success( + context.getString(R.string.channels), + it, + row.viewOptions, + rowType = row, + ) + } } is HomeRowConfig.Suggestions -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index 01bdba3f..2bc9943f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -70,13 +70,14 @@ class LatestNextUpService remove(BaseItemKind.EPISODE) } }, + enableTotalRecordCount = false, ) val items = api.itemsApi .getResumeItems(request) .content .items - .map { BaseItem.from(it, api, useSeriesForPrimary) } + .map { BaseItem(it, useSeriesForPrimary) } return items } @@ -105,13 +106,14 @@ class LatestNextUpService enableUserData = true, enableRewatching = enableRewatching, nextUpDateCutoff = nextUpDateCutoff, + enableTotalRecordCount = false, ) val nextUp = api.tvShowsApi .getNextUp(request) .content .items - .map { BaseItem.from(it, api, useSeriesForPrimary) } + .map { BaseItem(it, useSeriesForPrimary) } .filter { val seriesId = it.data.seriesId if (seriesId != null && seriesId in removedSeries) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt index 93e9adcc..815c6f35 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt @@ -14,8 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -35,10 +33,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults -import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.SeerrAvailability @@ -288,88 +284,6 @@ fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) { } } -@Composable -fun DiscoverViewMoreCard( - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val focused by interactionSource.collectIsFocusedAsState() - val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) - val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) - var focusedAfterDelay by remember { mutableStateOf(false) } - - val hideOverlayDelay = 500L - if (focused) { - LaunchedEffect(Unit) { - delay(hideOverlayDelay) - if (focused) { - focusedAfterDelay = true - } else { - focusedAfterDelay = false - } - } - } else { - focusedAfterDelay = false - } - val width = Cards.height2x3 * AspectRatios.TALL - val height = Dp.Unspecified * (1f / AspectRatios.TALL) - Column( - verticalArrangement = Arrangement.spacedBy(spaceBetween), - modifier = modifier.size(width, height), - ) { - Card( - modifier = - Modifier - .size(Dp.Unspecified, Cards.height2x3) - .aspectRatio(AspectRatios.TALL), - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - colors = - CardDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), - ), - ) { - Box( - modifier = - Modifier - .fillMaxSize(), - ) { - Icon( - imageVector = Icons.Default.ArrowForward, - tint = MaterialTheme.colorScheme.onSurface, - contentDescription = "View more", - modifier = Modifier.fillMaxSize(), - ) - } - } - - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .padding(bottom = spaceBelow) - .fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.view_more), - maxLines = 1, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - } - } -} - @PreviewTvSpec @Composable private fun Preview() { @@ -378,11 +292,6 @@ private fun Preview() { PendingIndicator() AvailableIndicator() PartiallyAvailableIndicator() - DiscoverViewMoreCard( - onClick = {}, - onLongClick = {}, - modifier = Modifier, - ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index c9f6a142..6544895b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -42,6 +42,8 @@ fun ItemRow( ) -> Unit, modifier: Modifier = Modifier, horizontalPadding: Dp = 16.dp, + showViewMore: Boolean = false, + viewMoreCardContent: @Composable (Modifier) -> Unit = {}, ) { val state = rememberLazyListState() val firstFocus = remember { FocusRequester() } @@ -107,6 +109,19 @@ fun ItemRow( onLongClick, ) } + if (showViewMore) { + item { + val cardModifier = + remember(items.size, position) { + if (position == items.size) { + Modifier.focusRequester(firstFocus) + } else { + Modifier + } + } + viewMoreCardContent.invoke(cardModifier) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt new file mode 100644 index 00000000..56748473 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt @@ -0,0 +1,149 @@ +package com.github.damontecres.wholphin.ui.cards + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified +import androidx.tv.material3.Card +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import kotlinx.coroutines.delay + +@Composable +fun ViewMoreCard( + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + aspectRatio: AspectRatio = AspectRatio.TALL, + size: DpSize = DpSize(width = Cards.height2x3 * aspectRatio.ratio, height = Cards.height2x3), + showTitle: Boolean = true, +) { + val focused by interactionSource.collectIsFocusedAsState() + val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) + val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) + var focusedAfterDelay by remember { mutableStateOf(false) } + + val hideOverlayDelay = 500L + if (focused) { + LaunchedEffect(Unit) { + delay(hideOverlayDelay) + if (focused) { + focusedAfterDelay = true + } else { + focusedAfterDelay = false + } + } + } else { + focusedAfterDelay = false + } + val width = + remember(size, aspectRatio) { + size.width.takeIf { it.isSpecified } ?: (size.height * aspectRatio.ratio) + } + val height = + remember(size, aspectRatio) { + size.height.takeIf { it.isSpecified } ?: (size.height * (1f / aspectRatio.ratio)) + } + Column( + verticalArrangement = Arrangement.spacedBy(spaceBetween), + modifier = modifier, + ) { + Card( + modifier = + Modifier + .size(width, height) + .aspectRatio(aspectRatio.ratio), + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + colors = + CardDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + ), + ) { + Box { + Icon( + imageVector = Icons.Default.ArrowForward, + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = "View more", + modifier = Modifier.fillMaxSize(), + ) + } + } + if (showTitle) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .width(width) + .padding(bottom = spaceBelow), + ) { + Text( + text = stringResource(R.string.view_more), + maxLines = 1, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = + Modifier + .width(width) + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + } + } + } +} + +@PreviewTvSpec +@Composable +private fun Preview() { + WholphinTheme { + Column { + ViewMoreCard( + onClick = {}, + onLongClick = {}, + modifier = Modifier.padding(16.dp), + aspectRatio = AspectRatio.TALL, + size = DpSize(width = Dp.Unspecified, height = Cards.heightEpisode), + showTitle = false, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index 4723627f..17fdcd37 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -242,6 +242,7 @@ fun GenreCardGrid( itemId: UUID, includeItemTypes: List?, modifier: Modifier = Modifier, + initialPosition: Int = 0, viewModel: GenreViewModel = hiltViewModel( creationCallback = { it.create(itemId, includeItemTypes) }, @@ -304,7 +305,7 @@ fun GenreCardGrid( showJumpButtons = false, showLetterButtons = true, modifier = Modifier.fillMaxSize(), - initialPosition = 0, + initialPosition = initialPosition, positionCallback = { columns, position -> }, columns = columns, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt index dff6d505..1aba204f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt @@ -21,16 +21,15 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.RequestHandler import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -38,9 +37,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest @HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class) class ItemGridViewModel @@ -48,25 +44,27 @@ class ItemGridViewModel constructor( private val api: ApiClient, private val navigationManager: NavigationManager, - @Assisted private val destination: Destination.ItemGrid, + @Assisted private val destination: Destination.ItemGrid<*>, ) : ViewModel() { val loading = MutableLiveData(LoadingState.Loading) val items = MutableLiveData>(listOf()) @AssistedFactory interface Factory { - fun create(destination: Destination.ItemGrid): ItemGridViewModel + fun create(destination: Destination.ItemGrid<*>): ItemGridViewModel } init { viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching items")) { - val request = - GetItemsRequest( - ids = destination.itemIds, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ) - val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() + val request = destination.request as Any + val pager = + ApiRequestPager( + api, + request, + destination.requestHandler as RequestHandler, + viewModelScope, + useSeriesForPrimary = true, + ).init() if (pager.isNotEmpty()) { pager.getBlocking(0) } @@ -83,11 +81,11 @@ class ItemGridViewModel } /** - * Display a grid of a list of arbitrary item IDs such as for [com.github.damontecres.wholphin.data.ExtrasItem] + * Display a grid of a list of arbitrary items [com.github.damontecres.wholphin.data.ExtrasItem] */ @Composable fun ItemGrid( - destination: Destination.ItemGrid, + destination: Destination.ItemGrid<*>, modifier: Modifier = Modifier, viewModel: ItemGridViewModel = hiltViewModel( @@ -132,7 +130,8 @@ fun ItemGrid( gridFocusRequester = focusRequester, showJumpButtons = false, showLetterButtons = false, - spacing = 24.dp, + initialPosition = destination.initialPosition, + spacing = destination.viewOptions.spacing.dp, cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) -> GridCard( item = item, @@ -140,10 +139,10 @@ fun ItemGrid( onLongClick = onLongClick, fillWidth = widthPx, modifier = mod, - imageAspectRatio = AspectRatios.WIDE, // TODO + imageAspectRatio = destination.viewOptions.aspectRatio.ratio, ) }, - columns = 3, + columns = destination.viewOptions.columns, modifier = Modifier.fillMaxSize(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index ad7010cf..72c2888f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -1,7 +1,6 @@ package com.github.damontecres.wholphin.ui.components import android.content.Context -import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -13,7 +12,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R @@ -28,6 +26,9 @@ import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SuggestionService +import com.github.damontecres.wholphin.services.SuggestionsResource +import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel @@ -42,123 +43,303 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.RequestHandler +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.Deferred +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber import java.util.UUID -/** - * Abstract [ViewModel] for the "Recommended" tab for a library - */ -abstract class RecommendedViewModel( - @param:ApplicationContext val context: Context, - val api: ApiClient, - val serverRepository: ServerRepository, - val navigationManager: NavigationManager, - val favoriteWatchManager: FavoriteWatchManager, - val mediaReportService: MediaReportService, - private val musicService: MusicService, - private val backdropService: BackdropService, - private val mediaManagementService: MediaManagementService, -) : ViewModel() { - abstract fun init() - - abstract val rows: MutableStateFlow> - - val loading = MutableLiveData(LoadingState.Loading) - - fun refreshItem( - position: RowColumn, - itemId: UUID, - ) { - viewModelScope.launchIO { - val row = rows.value.getOrNull(position.row) - if (row is HomeRowLoadingState.Success) { - (row.items as? ApiRequestPager<*>)?.refreshItem(position.column, itemId) - } - } - } - - fun setWatched( - position: RowColumn, - itemId: UUID, - watched: Boolean, - ) { - viewModelScope.launchIO { - favoriteWatchManager.setWatched(itemId, watched) - refreshItem(position, itemId) - } - } - - fun setFavorite( - position: RowColumn, - itemId: UUID, - watched: Boolean, - ) { - viewModelScope.launchIO { - favoriteWatchManager.setFavorite(itemId, watched) - refreshItem(position, itemId) - } - } - - fun updateBackdrop(item: BaseItem) { - viewModelScope.launchIO { - backdropService.submit(item) - } - } - - abstract fun update( - @StringRes title: Int, - row: HomeRowLoadingState, - ): HomeRowLoadingState - - fun update( - @StringRes title: Int, - viewOptions: HomeRowViewOptions = HomeRowViewOptions(), - block: suspend () -> List, - ): Deferred = - viewModelScope.async(Dispatchers.IO) { - val titleStr = context.getString(title) - val row = - try { - HomeRowLoadingState.Success(titleStr, block.invoke(), viewOptions) - } catch (ex: Exception) { - HomeRowLoadingState.Error(titleStr, null, ex) - } - update(title, row) +@HiltViewModel(assistedFactory = RecommendedViewModel.Factory::class) +class RecommendedViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + val serverRepository: ServerRepository, + val navigationManager: NavigationManager, + private val userPreferencesService: UserPreferencesService, + private val favoriteWatchManager: FavoriteWatchManager, + private val musicService: MusicService, + private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, + private val suggestionService: SuggestionService, + val mediaReportService: MediaReportService, + @Assisted private val parentId: UUID, + @Assisted private val suggestionsType: BaseItemKind, + @Assisted private val recommendedRows: List>, + @Assisted private val viewOptions: HomeRowViewOptions, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create( + parentId: UUID, + suggestionsType: BaseItemKind, + recommendedRows: List>, + viewOptions: HomeRowViewOptions, + ): RecommendedViewModel } - fun deleteItem( - position: RowColumn, - item: BaseItem, - ) { - deleteItem(context, mediaManagementService, item) { + private val _state = MutableStateFlow(RecommendedState()) + val state: StateFlow = _state + + fun init() { viewModelScope.launchDefault { - val row = rows.value.getOrNull(position.row) - if (row is HomeRowLoadingState.Success) { - (row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column) + val limit = + userPreferencesService.flow + .first() + .appPreferences.homePagePreferences.maxItemsPerRow + _state.update { + it.copy( + loading = LoadingState.Loading, + rows = + recommendedRows.map { HomeRowLoadingState.Loading(context.getString(it.title)) } + + listOf(HomeRowLoadingState.Loading(context.getString(R.string.suggestions))), + ) } + val jobs = + recommendedRows.mapIndexed { index, row -> + val title = context.getString(row.title) + viewModelScope.launchIO { + val result = + try { + HomeRowLoadingState.Success( + title, + execute(row, limit), + viewOptions, + ) + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching %s", title) + HomeRowLoadingState.Error(title, null, ex) + } + _state.update { + it.copy( + rows = + it.rows.toMutableList().apply { + set(index, result) + }, + ) + } + } + } + fetchSuggestions() + jobs.forEachIndexed { index, job -> + job.join() + val row = state.value.rows[index] + if (row is HomeRowLoadingState.Success && row.items.isNotEmpty()) { + // Report loading success once the first non-empty row is ready + _state.update { it.copy(loading = LoadingState.Success) } + } + } + } + } + + private suspend fun execute( + row: RecommendedRow, + limit: Int?, + ): List = + if (limit != null) { + val request = row.handler.prepare(row.request, 0, limit, false) + row.handler + .execute(api, request) + .toBaseItems(api, true) + } else { + ApiRequestPager( + api, + row.request, + row.handler, + viewModelScope, + useSeriesForPrimary = true, + ).init() + } + + private fun fetchSuggestions() { + viewModelScope.launch(Dispatchers.IO) { + val title = context.getString(R.string.suggestions) + try { + suggestionService + .getSuggestionsFlow(parentId, suggestionsType) + .collect { resource -> + val result = + when (resource) { + is SuggestionsResource.Loading -> { + HomeRowLoadingState.Loading(title) + } + + is SuggestionsResource.Success -> { + HomeRowLoadingState.Success( + title, + resource.items, + viewOptions, + ) + } + + is SuggestionsResource.Empty -> { + HomeRowLoadingState.Success( + title, + emptyList(), + viewOptions, + ) + } + } + _state.update { + it.copy( + rows = + it.rows.toMutableList().apply { + set(lastIndex, result) + }, + ) + } + } + } catch (_: CancellationException) { + // no-op + } catch (ex: Exception) { + Timber.e(ex, "Failed to fetch suggestions") + _state.update { + it.copy( + rows = + it.rows.toMutableList().apply { + set(lastIndex, HomeRowLoadingState.Error(title, null, ex)) + }, + ) + } + } + } + } + + fun refreshItem( + position: RowColumn, + itemId: UUID, + ) { + viewModelScope.launchIO { + val row = state.value.rows.getOrNull(position.row) + if (row is HomeRowLoadingState.Success) { + (row.items as? ApiRequestPager<*>)?.refreshItem(position.column, itemId) + } + } + } + + fun setWatched( + position: RowColumn, + itemId: UUID, + watched: Boolean, + ) { + viewModelScope.launchIO { + favoriteWatchManager.setWatched(itemId, watched) + refreshItem(position, itemId) + } + } + + fun setFavorite( + position: RowColumn, + itemId: UUID, + watched: Boolean, + ) { + viewModelScope.launchIO { + favoriteWatchManager.setFavorite(itemId, watched) + refreshItem(position, itemId) + } + } + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } + + fun deleteItem( + position: RowColumn, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + val row = state.value.rows.getOrNull(position.row) + if (row is HomeRowLoadingState.Success) { + (row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column) + } + } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + fun addToQueue( + item: BaseItem, + index: Int, + ) = addToQueue(api, musicService, item, index) + + fun onClickViewMore( + position: RowColumn, + row: HomeRowLoadingState.Success, + ) { + if (position.row in recommendedRows.indices) { + val recommendedRow = recommendedRows[position.row] as RecommendedRow + navigationManager.navigateTo( + Destination.ItemGrid( + title = row.title, + titleRes = recommendedRow.title, + request = recommendedRow.request, + requestHandler = recommendedRow.handler, + initialPosition = row.items.size, + viewOptions = + ViewOptions( + aspectRatio = viewOptions.aspectRatio, + ), + ), + ) + } else { + // Suggestions + navigationManager.navigateTo( + Destination.ItemGrid( + title = row.title, + titleRes = R.string.suggestions, + request = + GetItemsRequest( + ids = row.items.mapNotNull { it?.id }, + ), + requestHandler = GetItemsRequestHandler, + initialPosition = row.items.size, + viewOptions = + ViewOptions( + aspectRatio = viewOptions.aspectRatio, + ), + ), + ) } } } - fun canDelete( - item: BaseItem, - appPreferences: AppPreferences, - ): Boolean = mediaManagementService.canDelete(item, appPreferences) +data class RecommendedState( + val loading: LoadingState = LoadingState.Pending, + val rows: List = emptyList(), +) - fun addToQueue( - item: BaseItem, - index: Int, - ) = addToQueue(api, musicService, item, index) -} +data class RecommendedRow( + val title: Int, + val handler: RequestHandler, + val request: T, +) @Composable fun RecommendedContent( @@ -176,12 +357,11 @@ fun RecommendedContent( OneTimeLaunchedEffect { viewModel.init() } - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val rows by viewModel.rows.collectAsState() + val state by viewModel.state.collectAsState() - when (val state = loading) { + when (val st = state.loading) { is LoadingState.Error -> { - ErrorMessage(state, modifier) + ErrorMessage(st, modifier) } LoadingState.Loading, @@ -222,7 +402,7 @@ fun RecommendedContent( } HomePageContent( - homeRows = rows, + homeRows = state.rows, position = position, onClickItem = { _, item -> viewModel.navigationManager.navigateTo(item.destination()) @@ -247,7 +427,7 @@ fun RecommendedContent( onFocusPosition = { position = it val nonEmptyRowBefore = - rows + state.rows .subList(0, it.row) .count { it is HomeRowLoadingState.Success && it.items.isEmpty() @@ -262,6 +442,8 @@ fun RecommendedContent( showClock = preferences.appPreferences.interfacePreferences.showClock, onUpdateBackdrop = viewModel::updateBackdrop, showLogo = preferences.appPreferences.interfacePreferences.showLogos, + showViewMore = true, + onClickViewMore = viewModel::onClickViewMore, modifier = modifier, ) } 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 71b762f0..34e2de2f 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,271 +1,83 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context -import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.datastore.core.DataStore import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService -import com.github.damontecres.wholphin.services.MusicService -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SuggestionService -import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.ui.toBaseItems -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.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.CancellationException -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest -import timber.log.Timber import java.util.UUID -@HiltViewModel(assistedFactory = RecommendedMovieViewModel.Factory::class) -class RecommendedMovieViewModel - @AssistedInject - constructor( - @ApplicationContext context: Context, - api: ApiClient, - musicService: MusicService, - serverRepository: ServerRepository, - private val preferencesDataStore: DataStore, - private val suggestionService: SuggestionService, - @Assisted val parentId: UUID, - navigationManager: NavigationManager, - favoriteWatchManager: FavoriteWatchManager, - mediaReportService: MediaReportService, - backdropService: BackdropService, - mediaManagementService: MediaManagementService, - ) : RecommendedViewModel( - context, - api, - serverRepository, - navigationManager, - favoriteWatchManager, - mediaReportService, - musicService, - backdropService, - mediaManagementService, - ) { - @AssistedFactory - interface Factory { - fun create(parentId: UUID): RecommendedMovieViewModel - } - - override val rows = - MutableStateFlow>( - rowTitles.keys.map { - HomeRowLoadingState.Pending( - context.getString(it), - ) - }, - ) - - override fun init() { - viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { - val itemsPerRow = - preferencesDataStore.data - .firstOrNull() - ?.homePagePreferences - ?.maxItemsPerRow - ?: AppPreference.HomePageItems.defaultValue.toInt() - try { - val resumeItemsRequest = - GetResumeItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - enableUserData = true, - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - val resumeItems = - GetResumeItemsRequestHandler - .execute(api, resumeItemsRequest) - .toBaseItems(api, false) - update( - R.string.continue_watching, - HomeRowLoadingState.Success( - context.getString(R.string.continue_watching), - resumeItems, - ), - ) - - if (resumeItems.isNotEmpty()) { - loading.setValueOnMain(LoadingState.Success) - } - } catch (ex: Exception) { - Timber.e(ex, "Exception fetching movie recommendations") - withContext(Dispatchers.Main) { - loading.value = LoadingState.Error(ex) - } - } - - val jobs = mutableListOf>() - - update(R.string.recently_released) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.PREMIERE_DATE), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - }.also(jobs::add) - - update(R.string.recently_added) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.DATE_CREATED), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - }.also(jobs::add) - - update(R.string.top_unwatched) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - recursive = true, - enableUserData = true, - isPlayed = false, - sortBy = listOf(ItemSortBy.COMMUNITY_RATING), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - }.also(jobs::add) - - viewModelScope.launch(Dispatchers.IO) { - try { - suggestionService - .getSuggestionsFlow(parentId, BaseItemKind.MOVIE) - .collect { resource -> - val state = - when (resource) { - is SuggestionsResource.Loading -> { - HomeRowLoadingState.Loading( - context.getString(R.string.suggestions), - ) - } - - is SuggestionsResource.Success -> { - HomeRowLoadingState.Success( - context.getString(R.string.suggestions), - resource.items, - ) - } - - is SuggestionsResource.Empty -> { - HomeRowLoadingState.Success( - context.getString(R.string.suggestions), - emptyList(), - ) - } - } - update(R.string.suggestions, state) - } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Failed to fetch suggestions") - update( - R.string.suggestions, - HomeRowLoadingState.Error( - title = context.getString(R.string.suggestions), - exception = ex, - ), - ) - } - } - - // If the continue watching row is empty, then wait until the first successful row - // is loaded before telling the UI that the page is loaded - if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { - for (i in 0.. - current.toMutableList().apply { set(rowTitles[title]!!, row) } - } - return 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, - ).mapIndexed { index, i -> i to index }.toMap() - } - } +private fun getRecommendedRows(parentId: UUID) = + listOf( + RecommendedRow( + title = R.string.continue_watching, + handler = GetResumeItemsRequestHandler, + request = + GetResumeItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + enableUserData = true, + enableTotalRecordCount = false, + ), + ), + RecommendedRow( + title = R.string.recently_released, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.PREMIERE_DATE), + sortOrder = listOf(SortOrder.DESCENDING), + enableTotalRecordCount = false, + ), + ), + RecommendedRow( + title = R.string.recently_added, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + enableTotalRecordCount = false, + ), + ), + RecommendedRow( + title = R.string.top_unwatched, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + recursive = true, + enableUserData = true, + isPlayed = false, + sortBy = listOf(ItemSortBy.COMMUNITY_RATING), + sortOrder = listOf(SortOrder.DESCENDING), + enableTotalRecordCount = false, + ), + ), + ) /** * The "recommended" tab of a movie library @@ -276,9 +88,16 @@ fun RecommendedMovie( parentId: UUID, onFocusPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, - viewModel: RecommendedMovieViewModel = - hiltViewModel( - creationCallback = { it.create(parentId) }, + viewModel: RecommendedViewModel = + hiltViewModel( + creationCallback = { + it.create( + parentId = parentId, + suggestionsType = BaseItemKind.MOVIE, + recommendedRows = getRecommendedRows(parentId), + viewOptions = HomeRowViewOptions(), + ) + }, ), ) { RecommendedContent( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt index 30576e7b..f1c11b7b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt @@ -1,237 +1,71 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context -import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.datastore.core.DataStore import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.HomeRowViewOptions -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService -import com.github.damontecres.wholphin.services.MusicService -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SuggestionService -import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.ui.toBaseItems -import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler -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.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest -import timber.log.Timber import java.util.UUID -@HiltViewModel(assistedFactory = RecommendedMusicViewModel.Factory::class) -class RecommendedMusicViewModel - @AssistedInject - constructor( - @ApplicationContext context: Context, - api: ApiClient, - musicService: MusicService, - serverRepository: ServerRepository, - private val preferencesDataStore: DataStore, - private val suggestionService: SuggestionService, - @Assisted val parentId: UUID, - navigationManager: NavigationManager, - favoriteWatchManager: FavoriteWatchManager, - mediaReportService: MediaReportService, - backdropService: BackdropService, - mediaManagementService: MediaManagementService, - ) : RecommendedViewModel( - context, - api, - serverRepository, - navigationManager, - favoriteWatchManager, - mediaReportService, - musicService, - backdropService, - mediaManagementService, - ) { - @AssistedFactory - interface Factory { - fun create(parentId: UUID): RecommendedMusicViewModel - } - - override val rows = - MutableStateFlow>( - rowTitles.keys.map { - HomeRowLoadingState.Pending( - context.getString(it), - ) - }, - ) - - override fun init() { - viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { - val itemsPerRow = - preferencesDataStore.data - .firstOrNull() - ?.homePagePreferences - ?.maxItemsPerRow - ?: AppPreference.HomePageItems.defaultValue.toInt() - - val jobs = mutableListOf>() - val viewOptions = - HomeRowViewOptions( - aspectRatio = AspectRatio.SQUARE, - heightDp = Cards.HEIGHT_EPISODE, - showTitles = true, - ) - - update(R.string.recently_released, viewOptions) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.PREMIERE_DATE), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - }.also(jobs::add) - - update(R.string.recently_added, viewOptions) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.DATE_CREATED), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - }.also(jobs::add) - - update(R.string.top_unwatched, viewOptions) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), - recursive = true, - enableUserData = true, - isPlayed = false, - sortBy = listOf(ItemSortBy.COMMUNITY_RATING), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - }.also(jobs::add) - - viewModelScope.launch(Dispatchers.IO) { - try { - suggestionService - .getSuggestionsFlow(parentId, BaseItemKind.MUSIC_ALBUM) - .collect { resource -> - val state = - when (resource) { - is SuggestionsResource.Loading -> { - HomeRowLoadingState.Loading( - context.getString(R.string.suggestions), - ) - } - - is SuggestionsResource.Success -> { - HomeRowLoadingState.Success( - context.getString(R.string.suggestions), - resource.items, - ) - } - - is SuggestionsResource.Empty -> { - HomeRowLoadingState.Success( - context.getString(R.string.suggestions), - emptyList(), - ) - } - } - update(R.string.suggestions, state) - } - } catch (ex: Exception) { - Timber.e(ex, "Failed to fetch suggestions") - update( - R.string.suggestions, - HomeRowLoadingState.Error( - title = context.getString(R.string.suggestions), - exception = ex, - ), - ) - } - } - - for (i in 0.. - current.toMutableList().apply { set(rowTitles[title]!!, row) } - } - return row - } - - companion object { - private val rowTitles = - listOf( - R.string.recently_released, - R.string.recently_added, - R.string.suggestions, - R.string.top_unwatched, - ).mapIndexed { index, i -> i to index }.toMap() - } - } +private fun getRecommendedRows(parentId: UUID) = + listOf( + RecommendedRow( + title = R.string.recently_released, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.PREMIERE_DATE), + sortOrder = listOf(SortOrder.DESCENDING), + enableTotalRecordCount = false, + ), + ), + RecommendedRow( + title = R.string.recently_added, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + enableTotalRecordCount = false, + ), + ), + RecommendedRow( + title = R.string.top_unwatched, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + recursive = true, + enableUserData = true, + isPlayed = false, + sortBy = listOf(ItemSortBy.COMMUNITY_RATING), + sortOrder = listOf(SortOrder.DESCENDING), + enableTotalRecordCount = false, + ), + ), + ) @Composable fun RecommendedMusic( @@ -239,9 +73,21 @@ fun RecommendedMusic( parentId: UUID, onFocusPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, - viewModel: RecommendedMusicViewModel = - hiltViewModel( - creationCallback = { it.create(parentId) }, + viewModel: RecommendedViewModel = + hiltViewModel( + creationCallback = { + it.create( + parentId = parentId, + suggestionsType = BaseItemKind.MUSIC_ALBUM, + recommendedRows = getRecommendedRows(parentId), + viewOptions = + HomeRowViewOptions( + aspectRatio = AspectRatio.SQUARE, + heightDp = Cards.HEIGHT_EPISODE, + showTitles = true, + ), + ) + }, ), ) { RecommendedContent( 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 ce50249e..4ec28a7b 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,317 +1,100 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context -import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.datastore.core.DataStore import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.LatestNextUpService -import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService -import com.github.damontecres.wholphin.services.MusicService -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SuggestionService -import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.ui.toBaseItems -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.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.CancellationException -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder 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 timber.log.Timber import java.util.UUID -@HiltViewModel(assistedFactory = RecommendedTvShowViewModel.Factory::class) -class RecommendedTvShowViewModel - @AssistedInject - constructor( - @ApplicationContext context: Context, - api: ApiClient, - musicService: MusicService, - serverRepository: ServerRepository, - private val preferencesDataStore: DataStore, - private val lastestNextUpService: LatestNextUpService, - private val suggestionService: SuggestionService, - @Assisted val parentId: UUID, - navigationManager: NavigationManager, - favoriteWatchManager: FavoriteWatchManager, - mediaReportService: MediaReportService, - backdropService: BackdropService, - mediaManagementService: MediaManagementService, - ) : RecommendedViewModel( - context, - api, - serverRepository, - navigationManager, - favoriteWatchManager, - mediaReportService, - musicService, - backdropService, - mediaManagementService, - ) { - @AssistedFactory - interface Factory { - fun create(parentId: UUID): RecommendedTvShowViewModel - } - - override val rows = - MutableStateFlow>( - rowTitles.keys.map { - HomeRowLoadingState.Pending( - context.getString(it), - ) - }, - ) - - override fun init() { - viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { - val preferences = - preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - val combineNextUp = preferences.homePagePreferences.combineContinueNext - val itemsPerRow = preferences.homePagePreferences.maxItemsPerRow - val userId = serverRepository.currentUser.value?.id - try { - val resumeItemsDeferred = - async(Dispatchers.IO) { - val resumeItemsRequest = - GetResumeItemsRequest( - userId = userId, - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.EPISODE), - enableUserData = true, - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetResumeItemsRequestHandler - .execute(api, resumeItemsRequest) - .toBaseItems(api, true) - } - - val nextUpItemsDeferred = - async(Dispatchers.IO) { - val nextUpRequest = - GetNextUpRequest( - userId = userId, - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = parentId, - limit = itemsPerRow, - enableResumable = false, - enableUserData = true, - enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp, - ) - GetNextUpRequestHandler - .execute(api, nextUpRequest) - .toBaseItems(api, true) - } - - val resumeItems = resumeItemsDeferred.await() - val nextUpItems = nextUpItemsDeferred.await() - - if (combineNextUp) { - val combined = lastestNextUpService.buildCombined(resumeItems, nextUpItems) - update( - R.string.continue_watching, - HomeRowLoadingState.Success( - context.getString(R.string.continue_watching), - combined, - ), - ) - update( - R.string.next_up, - HomeRowLoadingState.Success(context.getString(R.string.next_up), listOf()), - ) - } else { - update( - R.string.continue_watching, - HomeRowLoadingState.Success( - context.getString(R.string.continue_watching), - resumeItems, - ), - ) - update( - R.string.next_up, - HomeRowLoadingState.Success(context.getString(R.string.next_up), nextUpItems), - ) - } - - if (resumeItems.isNotEmpty() || nextUpItems.isNotEmpty()) { - loading.setValueOnMain(LoadingState.Success) - } - } catch (ex: Exception) { - Timber.e(ex, "Exception fetching tv recommendations") - withContext(Dispatchers.Main) { - loading.value = LoadingState.Error(ex) - } - } - - val jobs = mutableListOf>() - - update(R.string.recently_released) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.EPISODE), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.PREMIERE_DATE), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) - }.also(jobs::add) - - update(R.string.recently_added) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.EPISODE), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.DATE_CREATED), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) - }.also(jobs::add) - - update(R.string.top_unwatched) { - val request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.SERIES), - recursive = true, - enableUserData = true, - isPlayed = false, - sortBy = listOf(ItemSortBy.COMMUNITY_RATING), - sortOrder = listOf(SortOrder.DESCENDING), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) - }.also(jobs::add) - - viewModelScope.launch(Dispatchers.IO) { - try { - suggestionService - .getSuggestionsFlow(parentId, BaseItemKind.SERIES) - .collect { resource -> - val state = - when (resource) { - is SuggestionsResource.Loading -> { - HomeRowLoadingState.Loading( - context.getString(R.string.suggestions), - ) - } - - is SuggestionsResource.Success -> { - HomeRowLoadingState.Success( - context.getString(R.string.suggestions), - resource.items, - ) - } - - is SuggestionsResource.Empty -> { - HomeRowLoadingState.Success( - context.getString(R.string.suggestions), - emptyList(), - ) - } - } - update(R.string.suggestions, state) - } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Failed to fetch suggestions") - update( - R.string.suggestions, - HomeRowLoadingState.Error( - title = context.getString(R.string.suggestions), - exception = ex, - ), - ) - } - } - - if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { - for (i in 0.. - current.toMutableList().apply { set(rowTitles[title]!!, row) } - } - return 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, - ).mapIndexed { index, i -> i to index }.toMap() - } - } +private fun getRecommendedRows( + parentId: UUID, + enableRewatching: Boolean, +) = listOf( + RecommendedRow( + title = R.string.continue_watching, + handler = GetResumeItemsRequestHandler, + request = + GetResumeItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.EPISODE), + enableUserData = true, + enableTotalRecordCount = false, + ), + ), + RecommendedRow( + title = R.string.next_up, + handler = GetNextUpRequestHandler, + request = + GetNextUpRequest( + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = parentId, + enableResumable = false, + enableUserData = true, + enableRewatching = enableRewatching, + ), + ), + RecommendedRow( + title = R.string.recently_released, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.EPISODE), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.PREMIERE_DATE), + sortOrder = listOf(SortOrder.DESCENDING), + enableTotalRecordCount = false, + ), + ), + RecommendedRow( + title = R.string.recently_added, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.EPISODE), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + enableTotalRecordCount = false, + ), + ), + RecommendedRow( + title = R.string.top_unwatched, + handler = GetItemsRequestHandler, + request = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.SERIES), + recursive = true, + enableUserData = true, + isPlayed = false, + sortBy = listOf(ItemSortBy.COMMUNITY_RATING), + sortOrder = listOf(SortOrder.DESCENDING), + enableTotalRecordCount = false, + ), + ), +) /** * The "recommended" tab of a TV show library @@ -322,9 +105,20 @@ fun RecommendedTvShow( parentId: UUID, onFocusPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, - viewModel: RecommendedTvShowViewModel = - hiltViewModel( - creationCallback = { it.create(parentId) }, + viewModel: RecommendedViewModel = + hiltViewModel( + creationCallback = { + it.create( + parentId = parentId, + suggestionsType = BaseItemKind.SERIES, + recommendedRows = + getRecommendedRows( + parentId, + preferences.appPreferences.homePagePreferences.enableRewatchingNextUp, + ), + viewOptions = HomeRowViewOptions(), + ) + }, ), ) { RecommendedContent( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt index 92605dfc..a0a7d9e4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt @@ -138,6 +138,7 @@ fun StudioCardGrid( itemId: UUID, includeItemTypes: List?, modifier: Modifier = Modifier, + initialPosition: Int = 0, viewModel: StudioViewModel = hiltViewModel( creationCallback = { it.create(itemId, includeItemTypes) }, @@ -200,7 +201,7 @@ fun StudioCardGrid( showJumpButtons = false, showLetterButtons = true, modifier = Modifier.fillMaxSize(), - initialPosition = 0, + initialPosition = initialPosition, positionCallback = { columns, position -> }, columns = columns, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 9fbf22ee..1d42848b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -123,7 +123,7 @@ fun CardGrid( initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) } - var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) } + var focusedIndex by rememberSaveable { mutableIntStateOf(startPosition) } val currentFocusedIndex by rememberUpdatedState(focusedIndex) val gridState = rememberLazyGridState( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt new file mode 100644 index 00000000..a83858cc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt @@ -0,0 +1,404 @@ +package com.github.damontecres.wholphin.ui.detail + +import android.content.Context +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.HomeSettingsService +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService +import com.github.damontecres.wholphin.services.MusicService +import com.github.damontecres.wholphin.services.NavDrawerService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.ThemeSongPlayer +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem +import com.github.damontecres.wholphin.services.tvAccess +import com.github.damontecres.wholphin.ui.cards.GridCard +import com.github.damontecres.wholphin.ui.components.ContextMenu +import com.github.damontecres.wholphin.ui.components.ContextMenuActions +import com.github.damontecres.wholphin.ui.components.ContextMenuDialog +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.GenreCardGrid +import com.github.damontecres.wholphin.ui.components.GridTitle +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.Optional +import com.github.damontecres.wholphin.ui.components.StudioCardGrid +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.playback.scale +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState +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.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.MediaType +import timber.log.Timber +import java.util.UUID + +@HiltViewModel(assistedFactory = HomeRowGridViewModel.Factory::class) +class HomeRowGridViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + private val navDrawerService: NavDrawerService, + private val homeSettingsService: HomeSettingsService, + private val favoriteWatchManager: FavoriteWatchManager, + private val backdropService: BackdropService, + private val navigationManager: NavigationManager, + private val themeSongPlayer: ThemeSongPlayer, + private val mediaManagementService: MediaManagementService, + private val musicService: MusicService, + val streamChoiceService: StreamChoiceService, + val mediaReportService: MediaReportService, + @Assisted private val title: String, + @Assisted private val rowConfig: HomeRowConfig, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create( + title: String, + rowConfig: HomeRowConfig, + ): HomeRowGridViewModel + } + + private val _state = MutableStateFlow(HomeRowGridState()) + val state: StateFlow = _state + + init { + viewModelScope.launchDefault { + when (rowConfig) { + is HomeRowConfig.Genres, + is HomeRowConfig.Studios, + -> { + // Genres & Studios will be looked up by another ViewModel, so just return + _state.update { + it.copy( + loading = + HomeRowLoadingState.Success( + title, + emptyList(), + rowConfig.viewOptions, + ), + ) + } + return@launchDefault + } + + else -> {} + } + try { + val preferences = userPreferencesService.getCurrent() + val prefs = preferences.appPreferences.homePagePreferences + serverRepository.currentUserDto.value?.let { userDto -> + val libraries = + navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) + val result = + homeSettingsService.fetchDataForRow( + row = rowConfig, + scope = viewModelScope, + prefs = prefs, + userDto = userDto, + libraries = libraries, + isRefresh = true, + limit = 1_000, // TODO + usePaging = true, + ) + Timber.v( + "Got %s items for %s", + (result as? HomeRowLoadingState.Success)?.items?.size, + rowConfig, + ) + _state.update { it.copy(loading = result) } + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching: %s", rowConfig) + _state.update { it.copy(loading = HomeRowLoadingState.Error(title, null, ex)) } + } + } + } + + fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + (state.value.loading as? HomeRowLoadingState.Success)?.let { + (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + } + + fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + (state.value.loading as? HomeRowLoadingState.Success)?.let { + (it.items as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + } + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } + + fun navigateTo(destination: Destination) { + navigationManager.navigateTo(destination) + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + // TODO refresh + } + } + } + } + +data class HomeRowGridState( + val loading: HomeRowLoadingState = HomeRowLoadingState.Pending(""), +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HomeRowGrid( + preferences: UserPreferences, + destination: Destination.MoreHomeRow, + modifier: Modifier = Modifier, + viewModel: HomeRowGridViewModel = + hiltViewModel( + creationCallback = { it.create(destination.title, destination.config) }, + ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + var position by rememberInt(destination.initialPosition) + val gridFocusRequester = remember { FocusRequester() } + val viewOptions = destination.config.viewOptions + + var showContextMenu by remember { mutableStateOf(null) } + var overviewDialog by remember { mutableStateOf(null) } + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + + val contextActions = + remember { + ContextMenuActions( + navigateTo = viewModel::navigateTo, + onClickWatch = { itemId, watched -> + viewModel.setWatched(position, itemId, watched) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(position, itemId, favorite) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onDeleteItem = { viewModel.deleteItem(position, it) }, + onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, + onChooseVersion = { _, _ -> + // Not supported on this page + }, + onChooseTracks = { result -> + // Not supported on this page + }, + onClearChosenStreams = { + // Not supported on this page + }, + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + GridTitle(destination.title) + + when (val st = state.loading) { + is HomeRowLoadingState.Error -> { + ErrorMessage(st.message, st.exception, Modifier.fillMaxSize()) + } + + is HomeRowLoadingState.Loading, + is HomeRowLoadingState.Pending, + -> { + LoadingPage(Modifier.fillMaxSize()) + } + + is HomeRowLoadingState.Success -> { + when (destination.config) { + is HomeRowConfig.Genres -> { + GenreCardGrid( + itemId = destination.config.parentId, + includeItemTypes = null, + modifier = Modifier.fillMaxSize(), + initialPosition = destination.initialPosition, + ) + } + + is HomeRowConfig.Studios -> { + StudioCardGrid( + itemId = destination.config.parentId, + includeItemTypes = null, + modifier = Modifier.fillMaxSize(), + initialPosition = destination.initialPosition, + ) + } + + else -> { + val onClickItem = + remember { + { index: Int, item: BaseItem -> + viewModel.navigateTo(item.destination(index)) + } + } + val onLongClickItem = + remember { + { index: Int, item: BaseItem -> + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = true, + item = item, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = + viewModel.canDelete( + item, + preferences.appPreferences, + ), + canRemoveContinueWatching = false, + canRemoveNextUp = false, // TODO + actions = contextActions, + ) + } + } + LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } + CardGrid( + pager = st.items, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPlay = { _, _ -> }, + letterPosition = { -1 }, + gridFocusRequester = gridFocusRequester, + showJumpButtons = false, + showLetterButtons = false, + modifier = Modifier.fillMaxSize(), + initialPosition = destination.initialPosition, + positionCallback = { columns, newPosition -> + position = newPosition + }, + cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> + GridCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageContentScale = viewOptions.contentScale.scale, + imageAspectRatio = viewOptions.aspectRatio.ratio, + imageType = viewOptions.imageType, + showTitle = true, // viewOptions.showTitles, + fillWidth = widthPx, + modifier = mod, + ) + }, + columns = if (viewOptions.aspectRatio.ratio > 1f) 4 else 6, + spacing = viewOptions.spacing.dp, + bringIntoViewSpec = LocalBringIntoViewSpec.current, + ) + } + } + } + } + } + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = + viewModel.serverRepository.currentUserDto.value + ?.policy + ?.isAdministrator == true, + onDismissRequest = { overviewDialog = null }, + ) + } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, + ) + } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + elevation = 3.dp, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt index 806a816a..039ef566 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt @@ -27,8 +27,8 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard -import com.github.damontecres.wholphin.ui.cards.DiscoverViewMoreCard import com.github.damontecres.wholphin.ui.cards.ItemRowTitle +import com.github.damontecres.wholphin.ui.cards.ViewMoreCard import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.rememberInt @@ -169,7 +169,7 @@ fun DiscoverItemRow( } if (enableViewMore) { item { - DiscoverViewMoreCard( + ViewMoreCard( onClick = remember { { 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 a3b32382..162f5f4a 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 @@ -42,6 +42,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.MaterialTheme @@ -57,6 +59,7 @@ import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.StudioCard +import com.github.damontecres.wholphin.ui.cards.ViewMoreCard import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.ContextMenu import com.github.damontecres.wholphin.ui.components.ContextMenuActions @@ -194,6 +197,14 @@ fun HomePage( viewModel.navigationManager.navigateTo(Destination.Playback(item)) } } + val onClickViewMore = + remember { + { _: RowColumn, row: HomeRowLoadingState.Success -> + viewModel.navigationManager.navigateTo( + Destination.MoreHomeRow(row.title, row.rowType!!, row.items.size), + ) + } + } HomePageContent( homeRows = homeRows, @@ -206,6 +217,7 @@ fun HomePage( showClock = preferences.appPreferences.interfacePreferences.showClock, onUpdateBackdrop = viewModel::updateBackdrop, showLogo = preferences.appPreferences.interfacePreferences.showLogos, + onClickViewMore = onClickViewMore, modifier = modifier, ) overviewDialog?.let { info -> @@ -268,6 +280,8 @@ fun HomePageContent( modifier = HeaderUtils.modifier, ) }, + showViewMore: Boolean = true, + onClickViewMore: (RowColumn, HomeRowLoadingState.Success) -> Unit = { _, _ -> }, ) { val focusedItem = remember(homeRows, position) { @@ -441,6 +455,34 @@ fun HomePageContent( .onKeyEvent { onKey(it) }, ) }, + showViewMore = showViewMore, + viewMoreCardContent = { mod -> + HomePageViewMoreCard( + isEpisode = row.items.last()?.type == BaseItemKind.EPISODE, + onClick = { + onClickViewMore.invoke( + RowColumn( + rowIndex, + r.items.size, + ), + r, + ) + }, + onLongClick = {}, + viewOptions = viewOptions, + modifier = + mod.onFocusChanged { + if (it.isFocused) { + currentOnFocusPosition.invoke( + RowColumn( + rowIndex, + r.items.size, + ), + ) + } + }, + ) + }, ) } else if (showEmptyRows) { FocusableItemRow( @@ -651,3 +693,29 @@ fun HomePageCardContent( } } } + +@Composable +fun HomePageViewMoreCard( + isEpisode: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + viewOptions: HomeRowViewOptions, + modifier: Modifier, +) { + val aspectRatio = + remember(isEpisode, viewOptions) { + if (isEpisode) { + viewOptions.episodeAspectRatio + } else { + viewOptions.aspectRatio + } + } + ViewMoreCard( + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + aspectRatio = aspectRatio, + size = DpSize(height = viewOptions.heightDp.dp, width = Dp.Unspecified), + showTitle = viewOptions.showTitles, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 3ec26f92..53f11aac 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -318,6 +318,7 @@ fun HomeSettingsPage( takeFocus = false, showEmptyRows = true, showLogo = preferences.appPreferences.interfacePreferences.showLogos, + showViewMore = false, modifier = Modifier .fillMaxHeight() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 9aed36cd..213527db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -8,12 +8,16 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.util.DiscoverRequestType +import com.github.damontecres.wholphin.util.RequestHandler import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE +import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind @@ -105,10 +109,20 @@ sealed class Destination( ) : Destination(false) @Serializable - data class ItemGrid( + data class ItemGrid( val title: String?, @param:StringRes val titleRes: Int?, - val itemIds: List, + @Contextual val request: T, + val requestHandler: RequestHandler, + val initialPosition: Int = 0, + val viewOptions: ViewOptions = ViewOptions(), + ) : Destination(false) + + @Serializable + data class MoreHomeRow( + val title: String, + val config: HomeRowConfig, + val initialPosition: Int, ) : Destination(false) @Serializable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 22dfc570..15c95717 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -23,6 +23,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv import com.github.damontecres.wholphin.ui.detail.DebugPage import com.github.damontecres.wholphin.ui.detail.FavoritesPage +import com.github.damontecres.wholphin.ui.detail.HomeRowGrid import com.github.damontecres.wholphin.ui.detail.PersonPage import com.github.damontecres.wholphin.ui.detail.PlaylistDetails import com.github.damontecres.wholphin.ui.detail.collection.CollectionDetails @@ -289,6 +290,15 @@ fun DestinationContent( ) } + is Destination.MoreHomeRow -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + HomeRowGrid( + preferences = preferences, + destination = destination, + modifier = modifier, + ) + } + is Destination.Recordings -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } CollectionFolderRecordings( @@ -299,7 +309,7 @@ fun DestinationContent( ) } - is Destination.ItemGrid -> { + is Destination.ItemGrid<*> -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } ItemGrid( destination, 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 a64acb70..32b66d04 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,9 +21,11 @@ import org.jellyfin.sdk.model.api.GetProgramsDto import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest +import org.jellyfin.sdk.model.api.request.GetRecordingsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetStudiosRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest @@ -328,3 +330,43 @@ val GetStudiosRequestHandler = request: GetStudiosRequest, ): Response = api.studiosApi.getStudios(request) } + +val GetRecordingsRequestHandler = + object : RequestHandler { + override fun prepare( + request: GetRecordingsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetRecordingsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) + + override suspend fun execute( + api: ApiClient, + request: GetRecordingsRequest, + ): Response = api.liveTvApi.getRecordings(request) + } + +val GetLiveTvChannelsRequestHandler = + object : RequestHandler { + override fun prepare( + request: GetLiveTvChannelsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetLiveTvChannelsRequest = + request.copy( + startIndex = startIndex, + limit = limit, +// enableTotalRecordCount = enableTotalRecordCount, + ) + + override suspend fun execute( + api: ApiClient, + request: GetLiveTvChannelsRequest, + ): Response = api.liveTvApi.getLiveTvChannels(request) + }