mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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:
parent
98ffaf51ac
commit
f4bdb8446e
21 changed files with 1417 additions and 1075 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -318,6 +318,7 @@ fun HomeSettingsPage(
|
|||
takeFocus = false,
|
||||
showEmptyRows = true,
|
||||
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||
showViewMore = false,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue