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
This commit is contained in:
Damontecres 2026-05-16 11:35:11 -04:00 committed by GitHub
parent 98ffaf51ac
commit f4bdb8446e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1417 additions and 1075 deletions

View file

@ -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

View file

@ -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<Library>,
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(
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,11 +805,20 @@ class HomeSettingsService
fields = DefaultItemFields,
recursive = true,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) }
.let {
.map { BaseItem.from(it, api, row.viewOptions.useSeries) }
}.let {
Success(
title,
it,
@ -824,15 +839,22 @@ class HomeSettingsService
limit = limit,
fields = DefaultItemFields,
)
val name =
api.userLibraryApi
.getItem(itemId = row.parentId)
.content.name
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 {
}.let {
Success(
name ?: context.getString(R.string.collection),
it,
@ -856,11 +878,20 @@ class HomeSettingsService
)
}
}
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 {
}.let {
Success(
row.name,
it,
@ -907,11 +938,20 @@ class HomeSettingsService
includeItemTypes = listOf(row.kind),
isFavorite = true,
)
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 {
}.let {
Success(
title,
it,
@ -931,12 +971,22 @@ class HomeSettingsService
limit = limit,
enableImages = true,
enableUserData = true,
enableTotalRecordCount = false,
)
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 {
}.let {
Success(
context.getString(R.string.active_recordings),
it,
@ -957,6 +1007,7 @@ class HomeSettingsService
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
imageTypeLimit = 1,
)
// paging not supported
api.liveTvApi
.getRecommendedPrograms(request)
.content.items
@ -972,14 +1023,26 @@ 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 {
)
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,

View file

@ -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) {

View file

@ -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,
)
}
}
}

View file

@ -42,6 +42,8 @@ fun <T> 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 <T> ItemRow(
onLongClick,
)
}
if (showViewMore) {
item {
val cardModifier =
remember(items.size, position) {
if (position == items.size) {
Modifier.focusRequester(firstFocus)
} else {
Modifier
}
}
viewMoreCardContent.invoke(cardModifier)
}
}
}
}
}

View file

@ -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,
)
}
}
}

View file

@ -242,6 +242,7 @@ fun GenreCardGrid(
itemId: UUID,
includeItemTypes: List<BaseItemKind>?,
modifier: Modifier = Modifier,
initialPosition: Int = 0,
viewModel: GenreViewModel =
hiltViewModel<GenreViewModel, GenreViewModel.Factory>(
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,

View file

@ -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>(LoadingState.Loading)
val items = MutableLiveData<List<BaseItem?>>(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<Any>,
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<ItemGridViewModel, ItemGridViewModel.Factory>(
@ -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(),
)
}

View file

@ -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,44 +43,194 @@ 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,
@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,
val favoriteWatchManager: FavoriteWatchManager,
val mediaReportService: MediaReportService,
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<RecommendedRow<*>>,
@Assisted private val viewOptions: HomeRowViewOptions,
) : ViewModel() {
abstract fun init()
@AssistedFactory
interface Factory {
fun create(
parentId: UUID,
suggestionsType: BaseItemKind,
recommendedRows: List<RecommendedRow<*>>,
viewOptions: HomeRowViewOptions,
): RecommendedViewModel
}
abstract val rows: MutableStateFlow<List<HomeRowLoadingState>>
private val _state = MutableStateFlow(RecommendedState())
val state: StateFlow<RecommendedState> = _state
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
fun init() {
viewModelScope.launchDefault {
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 <T> execute(
row: RecommendedRow<T>,
limit: Int?,
): List<BaseItem?> =
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 = rows.value.getOrNull(position.row)
val row = state.value.rows.getOrNull(position.row)
if (row is HomeRowLoadingState.Success) {
(row.items as? ApiRequestPager<*>)?.refreshItem(position.column, itemId)
}
@ -114,34 +265,13 @@ abstract class RecommendedViewModel(
}
}
abstract fun update(
@StringRes title: Int,
row: HomeRowLoadingState,
): HomeRowLoadingState
fun update(
@StringRes title: Int,
viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
block: suspend () -> List<BaseItem>,
): Deferred<HomeRowLoadingState> =
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)
}
fun deleteItem(
position: RowColumn,
item: BaseItem,
) {
deleteItem(context, mediaManagementService, item) {
viewModelScope.launchDefault {
val row = rows.value.getOrNull(position.row)
val row = state.value.rows.getOrNull(position.row)
if (row is HomeRowLoadingState.Success) {
(row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column)
}
@ -158,7 +288,58 @@ abstract class RecommendedViewModel(
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<Any>
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,
),
),
)
}
}
}
data class RecommendedState(
val loading: LoadingState = LoadingState.Pending,
val rows: List<HomeRowLoadingState> = emptyList(),
)
data class RecommendedRow<T>(
val title: Int,
val handler: RequestHandler<T>,
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,
)
}

View file

@ -1,142 +1,40 @@
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<AppPreferences>,
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<List<HomeRowLoadingState>>(
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 =
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,
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<Deferred<HomeRowLoadingState>>()
update(R.string.recently_released) {
val request =
),
RecommendedRow(
title = R.string.recently_released,
handler = GetItemsRequestHandler,
request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@ -145,15 +43,13 @@ class RecommendedMovieViewModel
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 =
),
),
RecommendedRow(
title = R.string.recently_added,
handler = GetItemsRequestHandler,
request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@ -162,15 +58,13 @@ class RecommendedMovieViewModel
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 =
),
),
RecommendedRow(
title = R.string.top_unwatched,
handler = GetItemsRequestHandler,
request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@ -180,92 +74,10 @@ class RecommendedMovieViewModel
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..<jobs.size) {
val result = jobs[i].await()
if (result.completed) {
Timber.v("First success")
loading.setValueOnMain(LoadingState.Success)
}
break
}
}
}
}
override fun update(
@StringRes title: Int,
row: HomeRowLoadingState,
): HomeRowLoadingState {
rows.update { current ->
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()
}
}
/**
* The "recommended" tab of a movie library
@ -276,9 +88,16 @@ fun RecommendedMovie(
parentId: UUID,
onFocusPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier,
viewModel: RecommendedMovieViewModel =
hiltViewModel<RecommendedMovieViewModel, RecommendedMovieViewModel.Factory>(
creationCallback = { it.create(parentId) },
viewModel: RecommendedViewModel =
hiltViewModel<RecommendedViewModel, RecommendedViewModel.Factory>(
creationCallback = {
it.create(
parentId = parentId,
suggestionsType = BaseItemKind.MOVIE,
recommendedRows = getRecommendedRows(parentId),
viewOptions = HomeRowViewOptions(),
)
},
),
) {
RecommendedContent(

View file

@ -1,115 +1,28 @@
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<AppPreferences>,
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<List<HomeRowLoadingState>>(
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<Deferred<HomeRowLoadingState>>()
val viewOptions =
HomeRowViewOptions(
aspectRatio = AspectRatio.SQUARE,
heightDp = Cards.HEIGHT_EPISODE,
showTitles = true,
)
update(R.string.recently_released, viewOptions) {
val request =
private fun getRecommendedRows(parentId: UUID) =
listOf(
RecommendedRow(
title = R.string.recently_released,
handler = GetItemsRequestHandler,
request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@ -118,15 +31,13 @@ class RecommendedMusicViewModel
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 =
),
),
RecommendedRow(
title = R.string.recently_added,
handler = GetItemsRequestHandler,
request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@ -135,15 +46,13 @@ class RecommendedMusicViewModel
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 =
),
),
RecommendedRow(
title = R.string.top_unwatched,
handler = GetItemsRequestHandler,
request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@ -153,85 +62,10 @@ class RecommendedMusicViewModel
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..<jobs.size) {
val result = jobs[i].await()
if (result.completed) {
Timber.v("First success")
loading.setValueOnMain(LoadingState.Success)
}
break
}
}
}
override fun update(
@StringRes title: Int,
row: HomeRowLoadingState,
): HomeRowLoadingState {
rows.update { current ->
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()
}
}
@Composable
fun RecommendedMusic(
@ -239,9 +73,21 @@ fun RecommendedMusic(
parentId: UUID,
onFocusPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier,
viewModel: RecommendedMusicViewModel =
hiltViewModel<RecommendedMusicViewModel, RecommendedMusicViewModel.Factory>(
creationCallback = { it.create(parentId) },
viewModel: RecommendedViewModel =
hiltViewModel<RecommendedViewModel, RecommendedViewModel.Factory>(
creationCallback = {
it.create(
parentId = parentId,
suggestionsType = BaseItemKind.MUSIC_ALBUM,
recommendedRows = getRecommendedRows(parentId),
viewOptions =
HomeRowViewOptions(
aspectRatio = AspectRatio.SQUARE,
heightDp = Cards.HEIGHT_EPISODE,
showTitles = true,
),
)
},
),
) {
RecommendedContent(

View file

@ -1,189 +1,57 @@
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<AppPreferences>,
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<List<HomeRowLoadingState>>(
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 =
private fun getRecommendedRows(
parentId: UUID,
enableRewatching: Boolean,
) = listOf(
RecommendedRow(
title = R.string.continue_watching,
handler = GetResumeItemsRequestHandler,
request =
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 =
),
),
RecommendedRow(
title = R.string.next_up,
handler = GetNextUpRequestHandler,
request =
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,
enableRewatching = enableRewatching,
),
)
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<Deferred<HomeRowLoadingState>>()
update(R.string.recently_released) {
val request =
RecommendedRow(
title = R.string.recently_released,
handler = GetItemsRequestHandler,
request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@ -192,15 +60,13 @@ class RecommendedTvShowViewModel
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 =
),
),
RecommendedRow(
title = R.string.recently_added,
handler = GetItemsRequestHandler,
request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@ -209,15 +75,13 @@ class RecommendedTvShowViewModel
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 =
),
),
RecommendedRow(
title = R.string.top_unwatched,
handler = GetItemsRequestHandler,
request =
GetItemsRequest(
parentId = parentId,
fields = SlimItemFields,
@ -227,91 +91,10 @@ class RecommendedTvShowViewModel
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..<jobs.size) {
val result = jobs[i].await()
if (result is HomeRowLoadingState.Success) {
Timber.v("First success")
loading.setValueOnMain(LoadingState.Success)
}
break
}
}
}
}
override fun update(
@StringRes title: Int,
row: HomeRowLoadingState,
): HomeRowLoadingState {
rows.update { current ->
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()
}
}
/**
* 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<RecommendedTvShowViewModel, RecommendedTvShowViewModel.Factory>(
creationCallback = { it.create(parentId) },
viewModel: RecommendedViewModel =
hiltViewModel<RecommendedViewModel, RecommendedViewModel.Factory>(
creationCallback = {
it.create(
parentId = parentId,
suggestionsType = BaseItemKind.SERIES,
recommendedRows =
getRecommendedRows(
parentId,
preferences.appPreferences.homePagePreferences.enableRewatchingNextUp,
),
viewOptions = HomeRowViewOptions(),
)
},
),
) {
RecommendedContent(

View file

@ -138,6 +138,7 @@ fun StudioCardGrid(
itemId: UUID,
includeItemTypes: List<BaseItemKind>?,
modifier: Modifier = Modifier,
initialPosition: Int = 0,
viewModel: StudioViewModel =
hiltViewModel<StudioViewModel, StudioViewModel.Factory>(
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,

View file

@ -123,7 +123,7 @@ fun <T : CardGridItem> 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(

View file

@ -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<HomeRowGridState> = _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<HomeRowGridViewModel, HomeRowGridViewModel.Factory>(
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<ContextMenu?>(null) }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(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,
)
}
}

View file

@ -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 {
{

View file

@ -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,
)
}

View file

@ -318,6 +318,7 @@ fun HomeSettingsPage(
takeFocus = false,
showEmptyRows = true,
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
showViewMore = false,
modifier =
Modifier
.fillMaxHeight()

View file

@ -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<T>(
val title: String?,
@param:StringRes val titleRes: Int?,
val itemIds: List<UUID>,
@Contextual val request: T,
val requestHandler: RequestHandler<T>,
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

View file

@ -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,

View file

@ -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<BaseItemDtoQueryResult> = api.studiosApi.getStudios(request)
}
val GetRecordingsRequestHandler =
object : RequestHandler<GetRecordingsRequest> {
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<BaseItemDtoQueryResult> = api.liveTvApi.getRecordings(request)
}
val GetLiveTvChannelsRequestHandler =
object : RequestHandler<GetLiveTvChannelsRequest> {
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<BaseItemDtoQueryResult> = api.liveTvApi.getLiveTvChannels(request)
}