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 androidx.annotation.StringRes
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
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.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
import org.jellyfin.sdk.model.api.ExtraType
|
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
|
* Represents "extras" for media such as behind-the-scenes or deleted scenes
|
||||||
|
|
@ -34,7 +40,24 @@ sealed interface ExtrasItem {
|
||||||
override val isPlayed: Boolean,
|
override val isPlayed: Boolean,
|
||||||
) : ExtrasItem {
|
) : ExtrasItem {
|
||||||
override val destination: Destination =
|
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
|
override val playedPercentage
|
||||||
get() = -1.0
|
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.playback.getTypeFor
|
||||||
import com.github.damontecres.wholphin.ui.toBaseItems
|
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
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.GetGenresRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
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.GetPersonsHandler
|
||||||
|
import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
|
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error
|
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.GetGenresRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
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.GetPersonsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
|
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
|
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
|
||||||
|
|
@ -572,6 +576,7 @@ class HomeSettingsService
|
||||||
libraries: List<Library>,
|
libraries: List<Library>,
|
||||||
limit: Int = prefs.maxItemsPerRow,
|
limit: Int = prefs.maxItemsPerRow,
|
||||||
isRefresh: Boolean,
|
isRefresh: Boolean,
|
||||||
|
usePaging: Boolean = false,
|
||||||
): HomeRowLoadingState =
|
): HomeRowLoadingState =
|
||||||
when (row) {
|
when (row) {
|
||||||
is HomeRowConfig.ContinueWatching -> {
|
is HomeRowConfig.ContinueWatching -> {
|
||||||
|
|
@ -631,10 +636,11 @@ class HomeSettingsService
|
||||||
Success(
|
Success(
|
||||||
title = context.getString(R.string.continue_watching),
|
title = context.getString(R.string.continue_watching),
|
||||||
items =
|
items =
|
||||||
latestNextUpService.buildCombined(
|
latestNextUpService
|
||||||
resume,
|
.buildCombined(
|
||||||
nextUp,
|
resume,
|
||||||
),
|
nextUp,
|
||||||
|
).take(limit),
|
||||||
viewOptions = row.viewOptions,
|
viewOptions = row.viewOptions,
|
||||||
rowType = row,
|
rowType = row,
|
||||||
)
|
)
|
||||||
|
|
@ -769,7 +775,7 @@ class HomeSettingsService
|
||||||
api.userLibraryApi
|
api.userLibraryApi
|
||||||
.getLatestMedia(request)
|
.getLatestMedia(request)
|
||||||
.content
|
.content
|
||||||
.map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) }
|
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||||
.let {
|
.let {
|
||||||
Success(
|
Success(
|
||||||
title,
|
title,
|
||||||
|
|
@ -799,18 +805,27 @@ class HomeSettingsService
|
||||||
fields = DefaultItemFields,
|
fields = DefaultItemFields,
|
||||||
recursive = true,
|
recursive = true,
|
||||||
)
|
)
|
||||||
GetItemsRequestHandler
|
if (usePaging) {
|
||||||
.execute(api, request)
|
ApiRequestPager(
|
||||||
.content.items
|
api,
|
||||||
.map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) }
|
request,
|
||||||
.let {
|
GetItemsRequestHandler,
|
||||||
Success(
|
scope,
|
||||||
title,
|
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||||
it,
|
).init()
|
||||||
row.viewOptions,
|
} else {
|
||||||
rowType = row,
|
GetItemsRequestHandler
|
||||||
)
|
.execute(api, request)
|
||||||
}
|
.content.items
|
||||||
|
.map { BaseItem.from(it, api, row.viewOptions.useSeries) }
|
||||||
|
}.let {
|
||||||
|
Success(
|
||||||
|
title,
|
||||||
|
it,
|
||||||
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.ByParent -> {
|
is HomeRowConfig.ByParent -> {
|
||||||
|
|
@ -824,22 +839,29 @@ class HomeSettingsService
|
||||||
limit = limit,
|
limit = limit,
|
||||||
fields = DefaultItemFields,
|
fields = DefaultItemFields,
|
||||||
)
|
)
|
||||||
val name =
|
|
||||||
api.userLibraryApi
|
val name = getItemName(row.parentId)
|
||||||
.getItem(itemId = row.parentId)
|
if (usePaging) {
|
||||||
.content.name
|
ApiRequestPager(
|
||||||
GetItemsRequestHandler
|
api,
|
||||||
.execute(api, request)
|
request,
|
||||||
.content.items
|
GetItemsRequestHandler,
|
||||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
scope,
|
||||||
.let {
|
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||||
Success(
|
).init()
|
||||||
name ?: context.getString(R.string.collection),
|
} else {
|
||||||
it,
|
GetItemsRequestHandler
|
||||||
row.viewOptions,
|
.execute(api, request)
|
||||||
rowType = row,
|
.content.items
|
||||||
)
|
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||||
}
|
}.let {
|
||||||
|
Success(
|
||||||
|
name ?: context.getString(R.string.collection),
|
||||||
|
it,
|
||||||
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.GetItems -> {
|
is HomeRowConfig.GetItems -> {
|
||||||
|
|
@ -856,18 +878,27 @@ class HomeSettingsService
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GetItemsRequestHandler
|
if (usePaging) {
|
||||||
.execute(api, request)
|
ApiRequestPager(
|
||||||
.content.items
|
api,
|
||||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
request,
|
||||||
.let {
|
GetItemsRequestHandler,
|
||||||
Success(
|
scope,
|
||||||
row.name,
|
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||||
it,
|
).init()
|
||||||
row.viewOptions,
|
} else {
|
||||||
rowType = row,
|
GetItemsRequestHandler
|
||||||
)
|
.execute(api, request)
|
||||||
}
|
.content.items
|
||||||
|
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||||
|
}.let {
|
||||||
|
Success(
|
||||||
|
row.name,
|
||||||
|
it,
|
||||||
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.Favorite -> {
|
is HomeRowConfig.Favorite -> {
|
||||||
|
|
@ -907,18 +938,27 @@ class HomeSettingsService
|
||||||
includeItemTypes = listOf(row.kind),
|
includeItemTypes = listOf(row.kind),
|
||||||
isFavorite = true,
|
isFavorite = true,
|
||||||
)
|
)
|
||||||
GetItemsRequestHandler
|
if (usePaging) {
|
||||||
.execute(api, request)
|
ApiRequestPager(
|
||||||
.content.items
|
api,
|
||||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
request,
|
||||||
.let {
|
GetItemsRequestHandler,
|
||||||
Success(
|
scope,
|
||||||
title,
|
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||||
it,
|
).init()
|
||||||
row.viewOptions,
|
} else {
|
||||||
rowType = row,
|
GetItemsRequestHandler
|
||||||
)
|
.execute(api, request)
|
||||||
}
|
.content.items
|
||||||
|
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||||
|
}.let {
|
||||||
|
Success(
|
||||||
|
title,
|
||||||
|
it,
|
||||||
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -931,19 +971,29 @@ class HomeSettingsService
|
||||||
limit = limit,
|
limit = limit,
|
||||||
enableImages = true,
|
enableImages = true,
|
||||||
enableUserData = true,
|
enableUserData = true,
|
||||||
|
enableTotalRecordCount = false,
|
||||||
)
|
)
|
||||||
api.liveTvApi
|
if (usePaging) {
|
||||||
.getRecordings(request)
|
ApiRequestPager(
|
||||||
.content.items
|
api,
|
||||||
.map { BaseItem(it, row.viewOptions.useSeries) }
|
request,
|
||||||
.let {
|
GetRecordingsRequestHandler,
|
||||||
Success(
|
scope,
|
||||||
context.getString(R.string.active_recordings),
|
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||||
it,
|
).init()
|
||||||
row.viewOptions,
|
} else {
|
||||||
rowType = row,
|
api.liveTvApi
|
||||||
)
|
.getRecordings(request)
|
||||||
}
|
.content.items
|
||||||
|
.map { BaseItem(it, row.viewOptions.useSeries) }
|
||||||
|
}.let {
|
||||||
|
Success(
|
||||||
|
context.getString(R.string.active_recordings),
|
||||||
|
it,
|
||||||
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.TvPrograms -> {
|
is HomeRowConfig.TvPrograms -> {
|
||||||
|
|
@ -957,6 +1007,7 @@ class HomeSettingsService
|
||||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
|
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
|
||||||
imageTypeLimit = 1,
|
imageTypeLimit = 1,
|
||||||
)
|
)
|
||||||
|
// paging not supported
|
||||||
api.liveTvApi
|
api.liveTvApi
|
||||||
.getRecommendedPrograms(request)
|
.getRecommendedPrograms(request)
|
||||||
.content.items
|
.content.items
|
||||||
|
|
@ -972,21 +1023,33 @@ class HomeSettingsService
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.TvChannels -> {
|
is HomeRowConfig.TvChannels -> {
|
||||||
api.liveTvApi
|
val request =
|
||||||
.getLiveTvChannels(
|
GetLiveTvChannelsRequest(
|
||||||
userId = userDto.id,
|
userId = userDto.id,
|
||||||
fields = DefaultItemFields,
|
fields = DefaultItemFields,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
enableImages = true,
|
enableImages = true,
|
||||||
).toBaseItems(api, row.viewOptions.useSeries)
|
)
|
||||||
.let {
|
if (usePaging) {
|
||||||
Success(
|
ApiRequestPager(
|
||||||
context.getString(R.string.channels),
|
api,
|
||||||
it,
|
request,
|
||||||
row.viewOptions,
|
GetLiveTvChannelsRequestHandler,
|
||||||
rowType = row,
|
scope,
|
||||||
)
|
useSeriesForPrimary = row.viewOptions.useSeries,
|
||||||
}
|
).init()
|
||||||
|
} else {
|
||||||
|
api.liveTvApi
|
||||||
|
.getLiveTvChannels(request)
|
||||||
|
.toBaseItems(api, row.viewOptions.useSeries)
|
||||||
|
}.let {
|
||||||
|
Success(
|
||||||
|
context.getString(R.string.channels),
|
||||||
|
it,
|
||||||
|
row.viewOptions,
|
||||||
|
rowType = row,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowConfig.Suggestions -> {
|
is HomeRowConfig.Suggestions -> {
|
||||||
|
|
|
||||||
|
|
@ -70,13 +70,14 @@ class LatestNextUpService
|
||||||
remove(BaseItemKind.EPISODE)
|
remove(BaseItemKind.EPISODE)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
enableTotalRecordCount = false,
|
||||||
)
|
)
|
||||||
val items =
|
val items =
|
||||||
api.itemsApi
|
api.itemsApi
|
||||||
.getResumeItems(request)
|
.getResumeItems(request)
|
||||||
.content
|
.content
|
||||||
.items
|
.items
|
||||||
.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
.map { BaseItem(it, useSeriesForPrimary) }
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,13 +106,14 @@ class LatestNextUpService
|
||||||
enableUserData = true,
|
enableUserData = true,
|
||||||
enableRewatching = enableRewatching,
|
enableRewatching = enableRewatching,
|
||||||
nextUpDateCutoff = nextUpDateCutoff,
|
nextUpDateCutoff = nextUpDateCutoff,
|
||||||
|
enableTotalRecordCount = false,
|
||||||
)
|
)
|
||||||
val nextUp =
|
val nextUp =
|
||||||
api.tvShowsApi
|
api.tvShowsApi
|
||||||
.getNextUp(request)
|
.getNextUp(request)
|
||||||
.content
|
.content
|
||||||
.items
|
.items
|
||||||
.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
.map { BaseItem(it, useSeriesForPrimary) }
|
||||||
.filter {
|
.filter {
|
||||||
val seriesId = it.data.seriesId
|
val seriesId = it.data.seriesId
|
||||||
if (seriesId != null && seriesId in removedSeries) {
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -35,10 +33,8 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.Card
|
import androidx.tv.material3.Card
|
||||||
import androidx.tv.material3.CardDefaults
|
import androidx.tv.material3.CardDefaults
|
||||||
import androidx.tv.material3.Icon
|
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
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
|
@PreviewTvSpec
|
||||||
@Composable
|
@Composable
|
||||||
private fun Preview() {
|
private fun Preview() {
|
||||||
|
|
@ -378,11 +292,6 @@ private fun Preview() {
|
||||||
PendingIndicator()
|
PendingIndicator()
|
||||||
AvailableIndicator()
|
AvailableIndicator()
|
||||||
PartiallyAvailableIndicator()
|
PartiallyAvailableIndicator()
|
||||||
DiscoverViewMoreCard(
|
|
||||||
onClick = {},
|
|
||||||
onLongClick = {},
|
|
||||||
modifier = Modifier,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ fun <T> ItemRow(
|
||||||
) -> Unit,
|
) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
horizontalPadding: Dp = 16.dp,
|
horizontalPadding: Dp = 16.dp,
|
||||||
|
showViewMore: Boolean = false,
|
||||||
|
viewMoreCardContent: @Composable (Modifier) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val state = rememberLazyListState()
|
val state = rememberLazyListState()
|
||||||
val firstFocus = remember { FocusRequester() }
|
val firstFocus = remember { FocusRequester() }
|
||||||
|
|
@ -107,6 +109,19 @@ fun <T> ItemRow(
|
||||||
onLongClick,
|
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,
|
itemId: UUID,
|
||||||
includeItemTypes: List<BaseItemKind>?,
|
includeItemTypes: List<BaseItemKind>?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
initialPosition: Int = 0,
|
||||||
viewModel: GenreViewModel =
|
viewModel: GenreViewModel =
|
||||||
hiltViewModel<GenreViewModel, GenreViewModel.Factory>(
|
hiltViewModel<GenreViewModel, GenreViewModel.Factory>(
|
||||||
creationCallback = { it.create(itemId, includeItemTypes) },
|
creationCallback = { it.create(itemId, includeItemTypes) },
|
||||||
|
|
@ -304,7 +305,7 @@ fun GenreCardGrid(
|
||||||
showJumpButtons = false,
|
showJumpButtons = false,
|
||||||
showLetterButtons = true,
|
showLetterButtons = true,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
initialPosition = 0,
|
initialPosition = initialPosition,
|
||||||
positionCallback = { columns, position ->
|
positionCallback = { columns, position ->
|
||||||
},
|
},
|
||||||
columns = columns,
|
columns = columns,
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,15 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
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.cards.GridCard
|
||||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
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.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import com.github.damontecres.wholphin.util.RequestHandler
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
|
@ -38,9 +37,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
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)
|
@HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class)
|
||||||
class ItemGridViewModel
|
class ItemGridViewModel
|
||||||
|
|
@ -48,25 +44,27 @@ class ItemGridViewModel
|
||||||
constructor(
|
constructor(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
@Assisted private val destination: Destination.ItemGrid,
|
@Assisted private val destination: Destination.ItemGrid<*>,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||||
val items = MutableLiveData<List<BaseItem?>>(listOf())
|
val items = MutableLiveData<List<BaseItem?>>(listOf())
|
||||||
|
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(destination: Destination.ItemGrid): ItemGridViewModel
|
fun create(destination: Destination.ItemGrid<*>): ItemGridViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching items")) {
|
viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching items")) {
|
||||||
val request =
|
val request = destination.request as Any
|
||||||
GetItemsRequest(
|
val pager =
|
||||||
ids = destination.itemIds,
|
ApiRequestPager(
|
||||||
sortBy = listOf(ItemSortBy.SORT_NAME),
|
api,
|
||||||
sortOrder = listOf(SortOrder.ASCENDING),
|
request,
|
||||||
)
|
destination.requestHandler as RequestHandler<Any>,
|
||||||
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
|
viewModelScope,
|
||||||
|
useSeriesForPrimary = true,
|
||||||
|
).init()
|
||||||
if (pager.isNotEmpty()) {
|
if (pager.isNotEmpty()) {
|
||||||
pager.getBlocking(0)
|
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
|
@Composable
|
||||||
fun ItemGrid(
|
fun ItemGrid(
|
||||||
destination: Destination.ItemGrid,
|
destination: Destination.ItemGrid<*>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: ItemGridViewModel =
|
viewModel: ItemGridViewModel =
|
||||||
hiltViewModel<ItemGridViewModel, ItemGridViewModel.Factory>(
|
hiltViewModel<ItemGridViewModel, ItemGridViewModel.Factory>(
|
||||||
|
|
@ -132,7 +130,8 @@ fun ItemGrid(
|
||||||
gridFocusRequester = focusRequester,
|
gridFocusRequester = focusRequester,
|
||||||
showJumpButtons = false,
|
showJumpButtons = false,
|
||||||
showLetterButtons = false,
|
showLetterButtons = false,
|
||||||
spacing = 24.dp,
|
initialPosition = destination.initialPosition,
|
||||||
|
spacing = destination.viewOptions.spacing.dp,
|
||||||
cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
|
cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) ->
|
||||||
GridCard(
|
GridCard(
|
||||||
item = item,
|
item = item,
|
||||||
|
|
@ -140,10 +139,10 @@ fun ItemGrid(
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
fillWidth = widthPx,
|
fillWidth = widthPx,
|
||||||
modifier = mod,
|
modifier = mod,
|
||||||
imageAspectRatio = AspectRatios.WIDE, // TODO
|
imageAspectRatio = destination.viewOptions.aspectRatio.ratio,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
columns = 3,
|
columns = destination.viewOptions.columns,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.ui.components
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -13,7 +12,6 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.R
|
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.MediaReportService
|
||||||
import com.github.damontecres.wholphin.services.MusicService
|
import com.github.damontecres.wholphin.services.MusicService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
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.services.deleteItem
|
||||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
|
|
@ -42,123 +43,303 @@ import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.main.HomePageContent
|
import com.github.damontecres.wholphin.ui.main.HomePageContent
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
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.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import com.github.damontecres.wholphin.util.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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
@HiltViewModel(assistedFactory = RecommendedViewModel.Factory::class)
|
||||||
* Abstract [ViewModel] for the "Recommended" tab for a library
|
class RecommendedViewModel
|
||||||
*/
|
@AssistedInject
|
||||||
abstract class RecommendedViewModel(
|
constructor(
|
||||||
@param:ApplicationContext val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
val api: ApiClient,
|
private val api: ApiClient,
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val favoriteWatchManager: FavoriteWatchManager,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
val mediaReportService: MediaReportService,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
private val musicService: MusicService,
|
private val musicService: MusicService,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
private val mediaManagementService: MediaManagementService,
|
private val mediaManagementService: MediaManagementService,
|
||||||
) : ViewModel() {
|
private val suggestionService: SuggestionService,
|
||||||
abstract fun init()
|
val mediaReportService: MediaReportService,
|
||||||
|
@Assisted private val parentId: UUID,
|
||||||
abstract val rows: MutableStateFlow<List<HomeRowLoadingState>>
|
@Assisted private val suggestionsType: BaseItemKind,
|
||||||
|
@Assisted private val recommendedRows: List<RecommendedRow<*>>,
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
@Assisted private val viewOptions: HomeRowViewOptions,
|
||||||
|
) : ViewModel() {
|
||||||
fun refreshItem(
|
@AssistedFactory
|
||||||
position: RowColumn,
|
interface Factory {
|
||||||
itemId: UUID,
|
fun create(
|
||||||
) {
|
parentId: UUID,
|
||||||
viewModelScope.launchIO {
|
suggestionsType: BaseItemKind,
|
||||||
val row = rows.value.getOrNull(position.row)
|
recommendedRows: List<RecommendedRow<*>>,
|
||||||
if (row is HomeRowLoadingState.Success) {
|
viewOptions: HomeRowViewOptions,
|
||||||
(row.items as? ApiRequestPager<*>)?.refreshItem(position.column, itemId)
|
): RecommendedViewModel
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setWatched(
|
|
||||||
position: RowColumn,
|
|
||||||
itemId: UUID,
|
|
||||||
watched: Boolean,
|
|
||||||
) {
|
|
||||||
viewModelScope.launchIO {
|
|
||||||
favoriteWatchManager.setWatched(itemId, watched)
|
|
||||||
refreshItem(position, itemId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setFavorite(
|
|
||||||
position: RowColumn,
|
|
||||||
itemId: UUID,
|
|
||||||
watched: Boolean,
|
|
||||||
) {
|
|
||||||
viewModelScope.launchIO {
|
|
||||||
favoriteWatchManager.setFavorite(itemId, watched)
|
|
||||||
refreshItem(position, itemId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateBackdrop(item: BaseItem) {
|
|
||||||
viewModelScope.launchIO {
|
|
||||||
backdropService.submit(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract fun update(
|
|
||||||
@StringRes title: Int,
|
|
||||||
row: HomeRowLoadingState,
|
|
||||||
): HomeRowLoadingState
|
|
||||||
|
|
||||||
fun update(
|
|
||||||
@StringRes title: Int,
|
|
||||||
viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
|
||||||
block: suspend () -> List<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(
|
private val _state = MutableStateFlow(RecommendedState())
|
||||||
position: RowColumn,
|
val state: StateFlow<RecommendedState> = _state
|
||||||
item: BaseItem,
|
|
||||||
) {
|
fun init() {
|
||||||
deleteItem(context, mediaManagementService, item) {
|
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
val row = rows.value.getOrNull(position.row)
|
val limit =
|
||||||
if (row is HomeRowLoadingState.Success) {
|
userPreferencesService.flow
|
||||||
(row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column)
|
.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 = state.value.rows.getOrNull(position.row)
|
||||||
|
if (row is HomeRowLoadingState.Success) {
|
||||||
|
(row.items as? ApiRequestPager<*>)?.refreshItem(position.column, itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWatched(
|
||||||
|
position: RowColumn,
|
||||||
|
itemId: UUID,
|
||||||
|
watched: Boolean,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
favoriteWatchManager.setWatched(itemId, watched)
|
||||||
|
refreshItem(position, itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFavorite(
|
||||||
|
position: RowColumn,
|
||||||
|
itemId: UUID,
|
||||||
|
watched: Boolean,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
favoriteWatchManager.setFavorite(itemId, watched)
|
||||||
|
refreshItem(position, itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateBackdrop(item: BaseItem) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
backdropService.submit(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteItem(
|
||||||
|
position: RowColumn,
|
||||||
|
item: BaseItem,
|
||||||
|
) {
|
||||||
|
deleteItem(context, mediaManagementService, item) {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
val row = state.value.rows.getOrNull(position.row)
|
||||||
|
if (row is HomeRowLoadingState.Success) {
|
||||||
|
(row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canDelete(
|
||||||
|
item: BaseItem,
|
||||||
|
appPreferences: AppPreferences,
|
||||||
|
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||||
|
|
||||||
|
fun addToQueue(
|
||||||
|
item: BaseItem,
|
||||||
|
index: Int,
|
||||||
|
) = addToQueue(api, musicService, item, index)
|
||||||
|
|
||||||
|
fun onClickViewMore(
|
||||||
|
position: RowColumn,
|
||||||
|
row: HomeRowLoadingState.Success,
|
||||||
|
) {
|
||||||
|
if (position.row in recommendedRows.indices) {
|
||||||
|
val recommendedRow = recommendedRows[position.row] as RecommendedRow<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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun canDelete(
|
data class RecommendedState(
|
||||||
item: BaseItem,
|
val loading: LoadingState = LoadingState.Pending,
|
||||||
appPreferences: AppPreferences,
|
val rows: List<HomeRowLoadingState> = emptyList(),
|
||||||
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
)
|
||||||
|
|
||||||
fun addToQueue(
|
data class RecommendedRow<T>(
|
||||||
item: BaseItem,
|
val title: Int,
|
||||||
index: Int,
|
val handler: RequestHandler<T>,
|
||||||
) = addToQueue(api, musicService, item, index)
|
val request: T,
|
||||||
}
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RecommendedContent(
|
fun RecommendedContent(
|
||||||
|
|
@ -176,12 +357,11 @@ fun RecommendedContent(
|
||||||
OneTimeLaunchedEffect {
|
OneTimeLaunchedEffect {
|
||||||
viewModel.init()
|
viewModel.init()
|
||||||
}
|
}
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val state by viewModel.state.collectAsState()
|
||||||
val rows by viewModel.rows.collectAsState()
|
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val st = state.loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
ErrorMessage(state, modifier)
|
ErrorMessage(st, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadingState.Loading,
|
LoadingState.Loading,
|
||||||
|
|
@ -222,7 +402,7 @@ fun RecommendedContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
HomePageContent(
|
HomePageContent(
|
||||||
homeRows = rows,
|
homeRows = state.rows,
|
||||||
position = position,
|
position = position,
|
||||||
onClickItem = { _, item ->
|
onClickItem = { _, item ->
|
||||||
viewModel.navigationManager.navigateTo(item.destination())
|
viewModel.navigationManager.navigateTo(item.destination())
|
||||||
|
|
@ -247,7 +427,7 @@ fun RecommendedContent(
|
||||||
onFocusPosition = {
|
onFocusPosition = {
|
||||||
position = it
|
position = it
|
||||||
val nonEmptyRowBefore =
|
val nonEmptyRowBefore =
|
||||||
rows
|
state.rows
|
||||||
.subList(0, it.row)
|
.subList(0, it.row)
|
||||||
.count {
|
.count {
|
||||||
it is HomeRowLoadingState.Success && it.items.isEmpty()
|
it is HomeRowLoadingState.Success && it.items.isEmpty()
|
||||||
|
|
@ -262,6 +442,8 @@ fun RecommendedContent(
|
||||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
onUpdateBackdrop = viewModel::updateBackdrop,
|
onUpdateBackdrop = viewModel::updateBackdrop,
|
||||||
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
|
showViewMore = true,
|
||||||
|
onClickViewMore = viewModel::onClickViewMore,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,271 +1,83 @@
|
||||||
package com.github.damontecres.wholphin.ui.components
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.github.damontecres.wholphin.R
|
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.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.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
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.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
|
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.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@HiltViewModel(assistedFactory = RecommendedMovieViewModel.Factory::class)
|
private fun getRecommendedRows(parentId: UUID) =
|
||||||
class RecommendedMovieViewModel
|
listOf(
|
||||||
@AssistedInject
|
RecommendedRow(
|
||||||
constructor(
|
title = R.string.continue_watching,
|
||||||
@ApplicationContext context: Context,
|
handler = GetResumeItemsRequestHandler,
|
||||||
api: ApiClient,
|
request =
|
||||||
musicService: MusicService,
|
GetResumeItemsRequest(
|
||||||
serverRepository: ServerRepository,
|
parentId = parentId,
|
||||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
fields = SlimItemFields,
|
||||||
private val suggestionService: SuggestionService,
|
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
||||||
@Assisted val parentId: UUID,
|
enableUserData = true,
|
||||||
navigationManager: NavigationManager,
|
enableTotalRecordCount = false,
|
||||||
favoriteWatchManager: FavoriteWatchManager,
|
),
|
||||||
mediaReportService: MediaReportService,
|
),
|
||||||
backdropService: BackdropService,
|
RecommendedRow(
|
||||||
mediaManagementService: MediaManagementService,
|
title = R.string.recently_released,
|
||||||
) : RecommendedViewModel(
|
handler = GetItemsRequestHandler,
|
||||||
context,
|
request =
|
||||||
api,
|
GetItemsRequest(
|
||||||
serverRepository,
|
parentId = parentId,
|
||||||
navigationManager,
|
fields = SlimItemFields,
|
||||||
favoriteWatchManager,
|
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
||||||
mediaReportService,
|
recursive = true,
|
||||||
musicService,
|
enableUserData = true,
|
||||||
backdropService,
|
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
|
||||||
mediaManagementService,
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
) {
|
enableTotalRecordCount = false,
|
||||||
@AssistedFactory
|
),
|
||||||
interface Factory {
|
),
|
||||||
fun create(parentId: UUID): RecommendedMovieViewModel
|
RecommendedRow(
|
||||||
}
|
title = R.string.recently_added,
|
||||||
|
handler = GetItemsRequestHandler,
|
||||||
override val rows =
|
request =
|
||||||
MutableStateFlow<List<HomeRowLoadingState>>(
|
GetItemsRequest(
|
||||||
rowTitles.keys.map {
|
parentId = parentId,
|
||||||
HomeRowLoadingState.Pending(
|
fields = SlimItemFields,
|
||||||
context.getString(it),
|
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
||||||
)
|
recursive = true,
|
||||||
},
|
enableUserData = true,
|
||||||
)
|
sortBy = listOf(ItemSortBy.DATE_CREATED),
|
||||||
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
override fun init() {
|
enableTotalRecordCount = false,
|
||||||
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
),
|
||||||
val itemsPerRow =
|
),
|
||||||
preferencesDataStore.data
|
RecommendedRow(
|
||||||
.firstOrNull()
|
title = R.string.top_unwatched,
|
||||||
?.homePagePreferences
|
handler = GetItemsRequestHandler,
|
||||||
?.maxItemsPerRow
|
request =
|
||||||
?: AppPreference.HomePageItems.defaultValue.toInt()
|
GetItemsRequest(
|
||||||
try {
|
parentId = parentId,
|
||||||
val resumeItemsRequest =
|
fields = SlimItemFields,
|
||||||
GetResumeItemsRequest(
|
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
||||||
parentId = parentId,
|
recursive = true,
|
||||||
fields = SlimItemFields,
|
enableUserData = true,
|
||||||
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
isPlayed = false,
|
||||||
enableUserData = true,
|
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
|
||||||
startIndex = 0,
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
limit = itemsPerRow,
|
enableTotalRecordCount = false,
|
||||||
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 =
|
|
||||||
GetItemsRequest(
|
|
||||||
parentId = parentId,
|
|
||||||
fields = SlimItemFields,
|
|
||||||
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
|
||||||
recursive = true,
|
|
||||||
enableUserData = true,
|
|
||||||
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
|
|
||||||
sortOrder = listOf(SortOrder.DESCENDING),
|
|
||||||
startIndex = 0,
|
|
||||||
limit = itemsPerRow,
|
|
||||||
enableTotalRecordCount = false,
|
|
||||||
)
|
|
||||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
|
||||||
}.also(jobs::add)
|
|
||||||
|
|
||||||
update(R.string.recently_added) {
|
|
||||||
val request =
|
|
||||||
GetItemsRequest(
|
|
||||||
parentId = parentId,
|
|
||||||
fields = SlimItemFields,
|
|
||||||
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
|
||||||
recursive = true,
|
|
||||||
enableUserData = true,
|
|
||||||
sortBy = listOf(ItemSortBy.DATE_CREATED),
|
|
||||||
sortOrder = listOf(SortOrder.DESCENDING),
|
|
||||||
startIndex = 0,
|
|
||||||
limit = itemsPerRow,
|
|
||||||
enableTotalRecordCount = false,
|
|
||||||
)
|
|
||||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
|
||||||
}.also(jobs::add)
|
|
||||||
|
|
||||||
update(R.string.top_unwatched) {
|
|
||||||
val request =
|
|
||||||
GetItemsRequest(
|
|
||||||
parentId = parentId,
|
|
||||||
fields = SlimItemFields,
|
|
||||||
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
|
||||||
recursive = true,
|
|
||||||
enableUserData = true,
|
|
||||||
isPlayed = false,
|
|
||||||
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
|
|
||||||
sortOrder = listOf(SortOrder.DESCENDING),
|
|
||||||
startIndex = 0,
|
|
||||||
limit = itemsPerRow,
|
|
||||||
enableTotalRecordCount = false,
|
|
||||||
)
|
|
||||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
|
||||||
}.also(jobs::add)
|
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
suggestionService
|
|
||||||
.getSuggestionsFlow(parentId, BaseItemKind.MOVIE)
|
|
||||||
.collect { resource ->
|
|
||||||
val state =
|
|
||||||
when (resource) {
|
|
||||||
is SuggestionsResource.Loading -> {
|
|
||||||
HomeRowLoadingState.Loading(
|
|
||||||
context.getString(R.string.suggestions),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is SuggestionsResource.Success -> {
|
|
||||||
HomeRowLoadingState.Success(
|
|
||||||
context.getString(R.string.suggestions),
|
|
||||||
resource.items,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is SuggestionsResource.Empty -> {
|
|
||||||
HomeRowLoadingState.Success(
|
|
||||||
context.getString(R.string.suggestions),
|
|
||||||
emptyList(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
update(R.string.suggestions, state)
|
|
||||||
}
|
|
||||||
} catch (ex: CancellationException) {
|
|
||||||
throw ex
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex, "Failed to fetch suggestions")
|
|
||||||
update(
|
|
||||||
R.string.suggestions,
|
|
||||||
HomeRowLoadingState.Error(
|
|
||||||
title = context.getString(R.string.suggestions),
|
|
||||||
exception = ex,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the continue watching row is empty, then wait until the first successful row
|
|
||||||
// is loaded before telling the UI that the page is loaded
|
|
||||||
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
|
|
||||||
for (i in 0..<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
|
* The "recommended" tab of a movie library
|
||||||
|
|
@ -276,9 +88,16 @@ fun RecommendedMovie(
|
||||||
parentId: UUID,
|
parentId: UUID,
|
||||||
onFocusPosition: (RowColumn) -> Unit,
|
onFocusPosition: (RowColumn) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: RecommendedMovieViewModel =
|
viewModel: RecommendedViewModel =
|
||||||
hiltViewModel<RecommendedMovieViewModel, RecommendedMovieViewModel.Factory>(
|
hiltViewModel<RecommendedViewModel, RecommendedViewModel.Factory>(
|
||||||
creationCallback = { it.create(parentId) },
|
creationCallback = {
|
||||||
|
it.create(
|
||||||
|
parentId = parentId,
|
||||||
|
suggestionsType = BaseItemKind.MOVIE,
|
||||||
|
recommendedRows = getRecommendedRows(parentId),
|
||||||
|
viewOptions = HomeRowViewOptions(),
|
||||||
|
)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
RecommendedContent(
|
RecommendedContent(
|
||||||
|
|
|
||||||
|
|
@ -1,237 +1,71 @@
|
||||||
package com.github.damontecres.wholphin.ui.components
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.github.damontecres.wholphin.R
|
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.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.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.AspectRatio
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.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.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.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@HiltViewModel(assistedFactory = RecommendedMusicViewModel.Factory::class)
|
private fun getRecommendedRows(parentId: UUID) =
|
||||||
class RecommendedMusicViewModel
|
listOf(
|
||||||
@AssistedInject
|
RecommendedRow(
|
||||||
constructor(
|
title = R.string.recently_released,
|
||||||
@ApplicationContext context: Context,
|
handler = GetItemsRequestHandler,
|
||||||
api: ApiClient,
|
request =
|
||||||
musicService: MusicService,
|
GetItemsRequest(
|
||||||
serverRepository: ServerRepository,
|
parentId = parentId,
|
||||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
fields = SlimItemFields,
|
||||||
private val suggestionService: SuggestionService,
|
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||||
@Assisted val parentId: UUID,
|
recursive = true,
|
||||||
navigationManager: NavigationManager,
|
enableUserData = true,
|
||||||
favoriteWatchManager: FavoriteWatchManager,
|
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
|
||||||
mediaReportService: MediaReportService,
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
backdropService: BackdropService,
|
enableTotalRecordCount = false,
|
||||||
mediaManagementService: MediaManagementService,
|
),
|
||||||
) : RecommendedViewModel(
|
),
|
||||||
context,
|
RecommendedRow(
|
||||||
api,
|
title = R.string.recently_added,
|
||||||
serverRepository,
|
handler = GetItemsRequestHandler,
|
||||||
navigationManager,
|
request =
|
||||||
favoriteWatchManager,
|
GetItemsRequest(
|
||||||
mediaReportService,
|
parentId = parentId,
|
||||||
musicService,
|
fields = SlimItemFields,
|
||||||
backdropService,
|
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||||
mediaManagementService,
|
recursive = true,
|
||||||
) {
|
enableUserData = true,
|
||||||
@AssistedFactory
|
sortBy = listOf(ItemSortBy.DATE_CREATED),
|
||||||
interface Factory {
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
fun create(parentId: UUID): RecommendedMusicViewModel
|
enableTotalRecordCount = false,
|
||||||
}
|
),
|
||||||
|
),
|
||||||
override val rows =
|
RecommendedRow(
|
||||||
MutableStateFlow<List<HomeRowLoadingState>>(
|
title = R.string.top_unwatched,
|
||||||
rowTitles.keys.map {
|
handler = GetItemsRequestHandler,
|
||||||
HomeRowLoadingState.Pending(
|
request =
|
||||||
context.getString(it),
|
GetItemsRequest(
|
||||||
)
|
parentId = parentId,
|
||||||
},
|
fields = SlimItemFields,
|
||||||
)
|
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||||
|
recursive = true,
|
||||||
override fun init() {
|
enableUserData = true,
|
||||||
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
isPlayed = false,
|
||||||
val itemsPerRow =
|
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
|
||||||
preferencesDataStore.data
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
.firstOrNull()
|
enableTotalRecordCount = false,
|
||||||
?.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 =
|
|
||||||
GetItemsRequest(
|
|
||||||
parentId = parentId,
|
|
||||||
fields = SlimItemFields,
|
|
||||||
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
|
||||||
recursive = true,
|
|
||||||
enableUserData = true,
|
|
||||||
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
|
|
||||||
sortOrder = listOf(SortOrder.DESCENDING),
|
|
||||||
startIndex = 0,
|
|
||||||
limit = itemsPerRow,
|
|
||||||
enableTotalRecordCount = false,
|
|
||||||
)
|
|
||||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
|
||||||
}.also(jobs::add)
|
|
||||||
|
|
||||||
update(R.string.recently_added, viewOptions) {
|
|
||||||
val request =
|
|
||||||
GetItemsRequest(
|
|
||||||
parentId = parentId,
|
|
||||||
fields = SlimItemFields,
|
|
||||||
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
|
||||||
recursive = true,
|
|
||||||
enableUserData = true,
|
|
||||||
sortBy = listOf(ItemSortBy.DATE_CREATED),
|
|
||||||
sortOrder = listOf(SortOrder.DESCENDING),
|
|
||||||
startIndex = 0,
|
|
||||||
limit = itemsPerRow,
|
|
||||||
enableTotalRecordCount = false,
|
|
||||||
)
|
|
||||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
|
||||||
}.also(jobs::add)
|
|
||||||
|
|
||||||
update(R.string.top_unwatched, viewOptions) {
|
|
||||||
val request =
|
|
||||||
GetItemsRequest(
|
|
||||||
parentId = parentId,
|
|
||||||
fields = SlimItemFields,
|
|
||||||
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
|
||||||
recursive = true,
|
|
||||||
enableUserData = true,
|
|
||||||
isPlayed = false,
|
|
||||||
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
|
|
||||||
sortOrder = listOf(SortOrder.DESCENDING),
|
|
||||||
startIndex = 0,
|
|
||||||
limit = itemsPerRow,
|
|
||||||
enableTotalRecordCount = false,
|
|
||||||
)
|
|
||||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
|
|
||||||
}.also(jobs::add)
|
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
suggestionService
|
|
||||||
.getSuggestionsFlow(parentId, BaseItemKind.MUSIC_ALBUM)
|
|
||||||
.collect { resource ->
|
|
||||||
val state =
|
|
||||||
when (resource) {
|
|
||||||
is SuggestionsResource.Loading -> {
|
|
||||||
HomeRowLoadingState.Loading(
|
|
||||||
context.getString(R.string.suggestions),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is SuggestionsResource.Success -> {
|
|
||||||
HomeRowLoadingState.Success(
|
|
||||||
context.getString(R.string.suggestions),
|
|
||||||
resource.items,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is SuggestionsResource.Empty -> {
|
|
||||||
HomeRowLoadingState.Success(
|
|
||||||
context.getString(R.string.suggestions),
|
|
||||||
emptyList(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
update(R.string.suggestions, state)
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex, "Failed to fetch suggestions")
|
|
||||||
update(
|
|
||||||
R.string.suggestions,
|
|
||||||
HomeRowLoadingState.Error(
|
|
||||||
title = context.getString(R.string.suggestions),
|
|
||||||
exception = ex,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i in 0..<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
|
@Composable
|
||||||
fun RecommendedMusic(
|
fun RecommendedMusic(
|
||||||
|
|
@ -239,9 +73,21 @@ fun RecommendedMusic(
|
||||||
parentId: UUID,
|
parentId: UUID,
|
||||||
onFocusPosition: (RowColumn) -> Unit,
|
onFocusPosition: (RowColumn) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: RecommendedMusicViewModel =
|
viewModel: RecommendedViewModel =
|
||||||
hiltViewModel<RecommendedMusicViewModel, RecommendedMusicViewModel.Factory>(
|
hiltViewModel<RecommendedViewModel, RecommendedViewModel.Factory>(
|
||||||
creationCallback = { it.create(parentId) },
|
creationCallback = {
|
||||||
|
it.create(
|
||||||
|
parentId = parentId,
|
||||||
|
suggestionsType = BaseItemKind.MUSIC_ALBUM,
|
||||||
|
recommendedRows = getRecommendedRows(parentId),
|
||||||
|
viewOptions =
|
||||||
|
HomeRowViewOptions(
|
||||||
|
aspectRatio = AspectRatio.SQUARE,
|
||||||
|
heightDp = Cards.HEIGHT_EPISODE,
|
||||||
|
showTitles = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
RecommendedContent(
|
RecommendedContent(
|
||||||
|
|
|
||||||
|
|
@ -1,317 +1,100 @@
|
||||||
package com.github.damontecres.wholphin.ui.components
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.github.damontecres.wholphin.R
|
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.AppPreferences
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
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.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
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.GetItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetNextUpRequestHandler
|
import com.github.damontecres.wholphin.util.GetNextUpRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
|
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
|
||||||
import com.github.damontecres.wholphin.util.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.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@HiltViewModel(assistedFactory = RecommendedTvShowViewModel.Factory::class)
|
private fun getRecommendedRows(
|
||||||
class RecommendedTvShowViewModel
|
parentId: UUID,
|
||||||
@AssistedInject
|
enableRewatching: Boolean,
|
||||||
constructor(
|
) = listOf(
|
||||||
@ApplicationContext context: Context,
|
RecommendedRow(
|
||||||
api: ApiClient,
|
title = R.string.continue_watching,
|
||||||
musicService: MusicService,
|
handler = GetResumeItemsRequestHandler,
|
||||||
serverRepository: ServerRepository,
|
request =
|
||||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
GetResumeItemsRequest(
|
||||||
private val lastestNextUpService: LatestNextUpService,
|
parentId = parentId,
|
||||||
private val suggestionService: SuggestionService,
|
fields = SlimItemFields,
|
||||||
@Assisted val parentId: UUID,
|
includeItemTypes = listOf(BaseItemKind.EPISODE),
|
||||||
navigationManager: NavigationManager,
|
enableUserData = true,
|
||||||
favoriteWatchManager: FavoriteWatchManager,
|
enableTotalRecordCount = false,
|
||||||
mediaReportService: MediaReportService,
|
),
|
||||||
backdropService: BackdropService,
|
),
|
||||||
mediaManagementService: MediaManagementService,
|
RecommendedRow(
|
||||||
) : RecommendedViewModel(
|
title = R.string.next_up,
|
||||||
context,
|
handler = GetNextUpRequestHandler,
|
||||||
api,
|
request =
|
||||||
serverRepository,
|
GetNextUpRequest(
|
||||||
navigationManager,
|
fields = SlimItemFields,
|
||||||
favoriteWatchManager,
|
imageTypeLimit = 1,
|
||||||
mediaReportService,
|
parentId = parentId,
|
||||||
musicService,
|
enableResumable = false,
|
||||||
backdropService,
|
enableUserData = true,
|
||||||
mediaManagementService,
|
enableRewatching = enableRewatching,
|
||||||
) {
|
),
|
||||||
@AssistedFactory
|
),
|
||||||
interface Factory {
|
RecommendedRow(
|
||||||
fun create(parentId: UUID): RecommendedTvShowViewModel
|
title = R.string.recently_released,
|
||||||
}
|
handler = GetItemsRequestHandler,
|
||||||
|
request =
|
||||||
override val rows =
|
GetItemsRequest(
|
||||||
MutableStateFlow<List<HomeRowLoadingState>>(
|
parentId = parentId,
|
||||||
rowTitles.keys.map {
|
fields = SlimItemFields,
|
||||||
HomeRowLoadingState.Pending(
|
includeItemTypes = listOf(BaseItemKind.EPISODE),
|
||||||
context.getString(it),
|
recursive = true,
|
||||||
)
|
enableUserData = true,
|
||||||
},
|
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
|
||||||
)
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
|
enableTotalRecordCount = false,
|
||||||
override fun init() {
|
),
|
||||||
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
|
),
|
||||||
val preferences =
|
RecommendedRow(
|
||||||
preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
title = R.string.recently_added,
|
||||||
val combineNextUp = preferences.homePagePreferences.combineContinueNext
|
handler = GetItemsRequestHandler,
|
||||||
val itemsPerRow = preferences.homePagePreferences.maxItemsPerRow
|
request =
|
||||||
val userId = serverRepository.currentUser.value?.id
|
GetItemsRequest(
|
||||||
try {
|
parentId = parentId,
|
||||||
val resumeItemsDeferred =
|
fields = SlimItemFields,
|
||||||
async(Dispatchers.IO) {
|
includeItemTypes = listOf(BaseItemKind.EPISODE),
|
||||||
val resumeItemsRequest =
|
recursive = true,
|
||||||
GetResumeItemsRequest(
|
enableUserData = true,
|
||||||
userId = userId,
|
sortBy = listOf(ItemSortBy.DATE_CREATED),
|
||||||
parentId = parentId,
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
fields = SlimItemFields,
|
enableTotalRecordCount = false,
|
||||||
includeItemTypes = listOf(BaseItemKind.EPISODE),
|
),
|
||||||
enableUserData = true,
|
),
|
||||||
startIndex = 0,
|
RecommendedRow(
|
||||||
limit = itemsPerRow,
|
title = R.string.top_unwatched,
|
||||||
enableTotalRecordCount = false,
|
handler = GetItemsRequestHandler,
|
||||||
)
|
request =
|
||||||
GetResumeItemsRequestHandler
|
GetItemsRequest(
|
||||||
.execute(api, resumeItemsRequest)
|
parentId = parentId,
|
||||||
.toBaseItems(api, true)
|
fields = SlimItemFields,
|
||||||
}
|
includeItemTypes = listOf(BaseItemKind.SERIES),
|
||||||
|
recursive = true,
|
||||||
val nextUpItemsDeferred =
|
enableUserData = true,
|
||||||
async(Dispatchers.IO) {
|
isPlayed = false,
|
||||||
val nextUpRequest =
|
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
|
||||||
GetNextUpRequest(
|
sortOrder = listOf(SortOrder.DESCENDING),
|
||||||
userId = userId,
|
enableTotalRecordCount = false,
|
||||||
fields = SlimItemFields,
|
),
|
||||||
imageTypeLimit = 1,
|
),
|
||||||
parentId = parentId,
|
)
|
||||||
limit = itemsPerRow,
|
|
||||||
enableResumable = false,
|
|
||||||
enableUserData = true,
|
|
||||||
enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp,
|
|
||||||
)
|
|
||||||
GetNextUpRequestHandler
|
|
||||||
.execute(api, nextUpRequest)
|
|
||||||
.toBaseItems(api, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
val resumeItems = resumeItemsDeferred.await()
|
|
||||||
val nextUpItems = nextUpItemsDeferred.await()
|
|
||||||
|
|
||||||
if (combineNextUp) {
|
|
||||||
val combined = lastestNextUpService.buildCombined(resumeItems, nextUpItems)
|
|
||||||
update(
|
|
||||||
R.string.continue_watching,
|
|
||||||
HomeRowLoadingState.Success(
|
|
||||||
context.getString(R.string.continue_watching),
|
|
||||||
combined,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
update(
|
|
||||||
R.string.next_up,
|
|
||||||
HomeRowLoadingState.Success(context.getString(R.string.next_up), listOf()),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
update(
|
|
||||||
R.string.continue_watching,
|
|
||||||
HomeRowLoadingState.Success(
|
|
||||||
context.getString(R.string.continue_watching),
|
|
||||||
resumeItems,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
update(
|
|
||||||
R.string.next_up,
|
|
||||||
HomeRowLoadingState.Success(context.getString(R.string.next_up), nextUpItems),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resumeItems.isNotEmpty() || nextUpItems.isNotEmpty()) {
|
|
||||||
loading.setValueOnMain(LoadingState.Success)
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex, "Exception fetching tv recommendations")
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
loading.value = LoadingState.Error(ex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
|
|
||||||
|
|
||||||
update(R.string.recently_released) {
|
|
||||||
val request =
|
|
||||||
GetItemsRequest(
|
|
||||||
parentId = parentId,
|
|
||||||
fields = SlimItemFields,
|
|
||||||
includeItemTypes = listOf(BaseItemKind.EPISODE),
|
|
||||||
recursive = true,
|
|
||||||
enableUserData = true,
|
|
||||||
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
|
|
||||||
sortOrder = listOf(SortOrder.DESCENDING),
|
|
||||||
startIndex = 0,
|
|
||||||
limit = itemsPerRow,
|
|
||||||
enableTotalRecordCount = false,
|
|
||||||
)
|
|
||||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
|
||||||
}.also(jobs::add)
|
|
||||||
|
|
||||||
update(R.string.recently_added) {
|
|
||||||
val request =
|
|
||||||
GetItemsRequest(
|
|
||||||
parentId = parentId,
|
|
||||||
fields = SlimItemFields,
|
|
||||||
includeItemTypes = listOf(BaseItemKind.EPISODE),
|
|
||||||
recursive = true,
|
|
||||||
enableUserData = true,
|
|
||||||
sortBy = listOf(ItemSortBy.DATE_CREATED),
|
|
||||||
sortOrder = listOf(SortOrder.DESCENDING),
|
|
||||||
startIndex = 0,
|
|
||||||
limit = itemsPerRow,
|
|
||||||
enableTotalRecordCount = false,
|
|
||||||
)
|
|
||||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
|
||||||
}.also(jobs::add)
|
|
||||||
|
|
||||||
update(R.string.top_unwatched) {
|
|
||||||
val request =
|
|
||||||
GetItemsRequest(
|
|
||||||
parentId = parentId,
|
|
||||||
fields = SlimItemFields,
|
|
||||||
includeItemTypes = listOf(BaseItemKind.SERIES),
|
|
||||||
recursive = true,
|
|
||||||
enableUserData = true,
|
|
||||||
isPlayed = false,
|
|
||||||
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
|
|
||||||
sortOrder = listOf(SortOrder.DESCENDING),
|
|
||||||
startIndex = 0,
|
|
||||||
limit = itemsPerRow,
|
|
||||||
enableTotalRecordCount = false,
|
|
||||||
)
|
|
||||||
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
|
|
||||||
}.also(jobs::add)
|
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
suggestionService
|
|
||||||
.getSuggestionsFlow(parentId, BaseItemKind.SERIES)
|
|
||||||
.collect { resource ->
|
|
||||||
val state =
|
|
||||||
when (resource) {
|
|
||||||
is SuggestionsResource.Loading -> {
|
|
||||||
HomeRowLoadingState.Loading(
|
|
||||||
context.getString(R.string.suggestions),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is SuggestionsResource.Success -> {
|
|
||||||
HomeRowLoadingState.Success(
|
|
||||||
context.getString(R.string.suggestions),
|
|
||||||
resource.items,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is SuggestionsResource.Empty -> {
|
|
||||||
HomeRowLoadingState.Success(
|
|
||||||
context.getString(R.string.suggestions),
|
|
||||||
emptyList(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
update(R.string.suggestions, state)
|
|
||||||
}
|
|
||||||
} catch (ex: CancellationException) {
|
|
||||||
throw ex
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex, "Failed to fetch suggestions")
|
|
||||||
update(
|
|
||||||
R.string.suggestions,
|
|
||||||
HomeRowLoadingState.Error(
|
|
||||||
title = context.getString(R.string.suggestions),
|
|
||||||
exception = ex,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
|
|
||||||
for (i in 0..<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
|
* The "recommended" tab of a TV show library
|
||||||
|
|
@ -322,9 +105,20 @@ fun RecommendedTvShow(
|
||||||
parentId: UUID,
|
parentId: UUID,
|
||||||
onFocusPosition: (RowColumn) -> Unit,
|
onFocusPosition: (RowColumn) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: RecommendedTvShowViewModel =
|
viewModel: RecommendedViewModel =
|
||||||
hiltViewModel<RecommendedTvShowViewModel, RecommendedTvShowViewModel.Factory>(
|
hiltViewModel<RecommendedViewModel, RecommendedViewModel.Factory>(
|
||||||
creationCallback = { it.create(parentId) },
|
creationCallback = {
|
||||||
|
it.create(
|
||||||
|
parentId = parentId,
|
||||||
|
suggestionsType = BaseItemKind.SERIES,
|
||||||
|
recommendedRows =
|
||||||
|
getRecommendedRows(
|
||||||
|
parentId,
|
||||||
|
preferences.appPreferences.homePagePreferences.enableRewatchingNextUp,
|
||||||
|
),
|
||||||
|
viewOptions = HomeRowViewOptions(),
|
||||||
|
)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
RecommendedContent(
|
RecommendedContent(
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ fun StudioCardGrid(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
includeItemTypes: List<BaseItemKind>?,
|
includeItemTypes: List<BaseItemKind>?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
initialPosition: Int = 0,
|
||||||
viewModel: StudioViewModel =
|
viewModel: StudioViewModel =
|
||||||
hiltViewModel<StudioViewModel, StudioViewModel.Factory>(
|
hiltViewModel<StudioViewModel, StudioViewModel.Factory>(
|
||||||
creationCallback = { it.create(itemId, includeItemTypes) },
|
creationCallback = { it.create(itemId, includeItemTypes) },
|
||||||
|
|
@ -200,7 +201,7 @@ fun StudioCardGrid(
|
||||||
showJumpButtons = false,
|
showJumpButtons = false,
|
||||||
showLetterButtons = true,
|
showLetterButtons = true,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
initialPosition = 0,
|
initialPosition = initialPosition,
|
||||||
positionCallback = { columns, position ->
|
positionCallback = { columns, position ->
|
||||||
},
|
},
|
||||||
columns = columns,
|
columns = columns,
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ fun <T : CardGridItem> CardGrid(
|
||||||
initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
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 currentFocusedIndex by rememberUpdatedState(focusedIndex)
|
||||||
val gridState =
|
val gridState =
|
||||||
rememberLazyGridState(
|
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.R
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
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.ItemRowTitle
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.ViewMoreCard
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
|
|
@ -169,7 +169,7 @@ fun DiscoverItemRow(
|
||||||
}
|
}
|
||||||
if (enableViewMore) {
|
if (enableViewMore) {
|
||||||
item {
|
item {
|
||||||
DiscoverViewMoreCard(
|
ViewMoreCard(
|
||||||
onClick =
|
onClick =
|
||||||
remember {
|
remember {
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.tv.material3.MaterialTheme
|
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.GenreCard
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.StudioCard
|
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.CircularProgress
|
||||||
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
import com.github.damontecres.wholphin.ui.components.ContextMenu
|
||||||
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
import com.github.damontecres.wholphin.ui.components.ContextMenuActions
|
||||||
|
|
@ -194,6 +197,14 @@ fun HomePage(
|
||||||
viewModel.navigationManager.navigateTo(Destination.Playback(item))
|
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(
|
HomePageContent(
|
||||||
homeRows = homeRows,
|
homeRows = homeRows,
|
||||||
|
|
@ -206,6 +217,7 @@ fun HomePage(
|
||||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
onUpdateBackdrop = viewModel::updateBackdrop,
|
onUpdateBackdrop = viewModel::updateBackdrop,
|
||||||
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
|
onClickViewMore = onClickViewMore,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
overviewDialog?.let { info ->
|
overviewDialog?.let { info ->
|
||||||
|
|
@ -268,6 +280,8 @@ fun HomePageContent(
|
||||||
modifier = HeaderUtils.modifier,
|
modifier = HeaderUtils.modifier,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
showViewMore: Boolean = true,
|
||||||
|
onClickViewMore: (RowColumn, HomeRowLoadingState.Success) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
val focusedItem =
|
val focusedItem =
|
||||||
remember(homeRows, position) {
|
remember(homeRows, position) {
|
||||||
|
|
@ -441,6 +455,34 @@ fun HomePageContent(
|
||||||
.onKeyEvent { onKey(it) },
|
.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) {
|
} else if (showEmptyRows) {
|
||||||
FocusableItemRow(
|
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,
|
takeFocus = false,
|
||||||
showEmptyRows = true,
|
showEmptyRows = true,
|
||||||
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
|
showViewMore = false,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxHeight()
|
.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.CollectionFolderFilter
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
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.preferences.PlayerBackend
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||||
import com.github.damontecres.wholphin.util.DiscoverRequestType
|
import com.github.damontecres.wholphin.util.DiscoverRequestType
|
||||||
|
import com.github.damontecres.wholphin.util.RequestHandler
|
||||||
import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE
|
import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE
|
||||||
|
import kotlinx.serialization.Contextual
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
|
@ -105,10 +109,20 @@ sealed class Destination(
|
||||||
) : Destination(false)
|
) : Destination(false)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ItemGrid(
|
data class ItemGrid<T>(
|
||||||
val title: String?,
|
val title: String?,
|
||||||
@param:StringRes val titleRes: Int?,
|
@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)
|
) : Destination(false)
|
||||||
|
|
||||||
@Serializable
|
@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.CollectionFolderTv
|
||||||
import com.github.damontecres.wholphin.ui.detail.DebugPage
|
import com.github.damontecres.wholphin.ui.detail.DebugPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.FavoritesPage
|
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.PersonPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.collection.CollectionDetails
|
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 -> {
|
is Destination.Recordings -> {
|
||||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
CollectionFolderRecordings(
|
CollectionFolderRecordings(
|
||||||
|
|
@ -299,7 +309,7 @@ fun DestinationContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is Destination.ItemGrid -> {
|
is Destination.ItemGrid<*> -> {
|
||||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
ItemGrid(
|
ItemGrid(
|
||||||
destination,
|
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.GetEpisodesRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
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.GetNextUpRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
|
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.GetResumeItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
|
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
|
||||||
|
|
@ -328,3 +330,43 @@ val GetStudiosRequestHandler =
|
||||||
request: GetStudiosRequest,
|
request: GetStudiosRequest,
|
||||||
): Response<BaseItemDtoQueryResult> = api.studiosApi.getStudios(request)
|
): 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