mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 08:01:20 +02:00
Customize home page (#803)
## Description This PR adds the ability to customize the home page in-app ### Features - Add, remove, & reorder rows - Persist the configuration locally - Save the configuration to the server, allowing to pull down on other devices - Adjust view options for rows such as card height, image type, preferring series images, or aspect ratio (similar to libraries) - Pull down the web client's home rows (no plugins are supported yet!) - Preview of the home page which is usable & updated as changes are made ### Row options These are row types that can be added in-app via the UI: - Continue watching - Next up - Combined continue waiting & next up - Recently added in a library - Recently released in a library - Genres in a library - Suggestions for a library (movie & TV show libraries only) - Favorite movies, tv shows, episodes, etc Additionally, there are more row types that don't have a UI to add them (yet): - Simple query to get items from a parent ID such as a collection or playlist - Complex query to get arbitrary items via the `/Items` API endpoint ### Dev notes Settings are loaded in order: 1. Locally saved 2. Remote saved 3. Fallback to default similar to Wholphin's original home rows The remote saved settings are stored via the display preferences API. I know some server admins would prefer to push a default setup to their clients. This PR does not have that ability, but it does define a straightforward API for defining the settings. Something like the potential server plugin work started in #625 could be slimmed down to expose a URL to be added in the load order. I'm also investigating integration with popular home page plugins to allow for further customization, but will take more time. ### Related issues Closes #399 Closes #361 Closes #282 Related to #340
This commit is contained in:
parent
b7679b3aa1
commit
fcba1c7444
35 changed files with 4297 additions and 321 deletions
|
|
@ -82,8 +82,10 @@ val PhotoItemFields =
|
|||
)
|
||||
|
||||
object Cards {
|
||||
val height2x3 = 172.dp
|
||||
val heightEpisode = height2x3 * .75f
|
||||
const val HEIGHT_2X3_DP = 172
|
||||
val height2x3 = HEIGHT_2X3_DP.dp
|
||||
val HEIGHT_EPISODE = (HEIGHT_2X3_DP * .75f).toInt().let { it - it % 4 }
|
||||
val heightEpisode = HEIGHT_EPISODE.dp
|
||||
val playedPercentHeight = 6.dp
|
||||
val serverUserCircle = height2x3 * .75f
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
package com.github.damontecres.wholphin.ui.cards
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -24,6 +28,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.colorResource
|
||||
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.dp
|
||||
|
|
@ -41,6 +46,7 @@ import com.github.damontecres.wholphin.ui.AspectRatios
|
|||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
|
||||
/**
|
||||
|
|
@ -60,6 +66,8 @@ fun BannerCard(
|
|||
cardHeight: Dp = 120.dp,
|
||||
aspectRatio: Float = AspectRatios.WIDE,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
imageType: ImageType = ImageType.PRIMARY,
|
||||
imageContentScale: ContentScale = ContentScale.FillBounds,
|
||||
) {
|
||||
val imageUrlService = LocalImageUrlService.current
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -74,14 +82,15 @@ fun BannerCard(
|
|||
}
|
||||
}
|
||||
val imageUrl =
|
||||
remember(item, fillHeight) {
|
||||
remember(item, fillHeight, imageType) {
|
||||
if (item != null) {
|
||||
imageUrlService.getItemImageUrl(
|
||||
item,
|
||||
ImageType.PRIMARY,
|
||||
fillWidth = null,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
item.imageUrlOverride
|
||||
?: imageUrlService.getItemImageUrl(
|
||||
item,
|
||||
imageType,
|
||||
fillWidth = null,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
@ -107,7 +116,7 @@ fun BannerCard(
|
|||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillBounds,
|
||||
contentScale = imageContentScale,
|
||||
onError = { imageError = true },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
|
@ -181,3 +190,82 @@ fun BannerCard(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BannerCardWithTitle(
|
||||
title: String?,
|
||||
subtitle: String?,
|
||||
item: BaseItem?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
cornerText: String? = null,
|
||||
played: Boolean = false,
|
||||
favorite: Boolean = false,
|
||||
playPercent: Double = 0.0,
|
||||
cardHeight: Dp = 120.dp,
|
||||
aspectRatio: Float = AspectRatios.WIDE,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
imageType: ImageType = ImageType.PRIMARY,
|
||||
imageContentScale: ContentScale = ContentScale.FillBounds,
|
||||
) {
|
||||
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)
|
||||
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||
val width = cardHeight * aspectRationToUse
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
modifier = modifier.width(width),
|
||||
) {
|
||||
BannerCard(
|
||||
name = null,
|
||||
item = item,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier,
|
||||
cornerText = cornerText,
|
||||
played = played,
|
||||
favorite = favorite,
|
||||
playPercent = playPercent,
|
||||
cardHeight = cardHeight,
|
||||
aspectRatio = aspectRatio,
|
||||
interactionSource = interactionSource,
|
||||
imageType = imageType,
|
||||
imageContentScale = imageContentScale,
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = spaceBelow)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = title ?: "",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
Text(
|
||||
text = subtitle ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,11 +42,29 @@ fun GenreCard(
|
|||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) = GenreCard(
|
||||
genreId = genre?.id,
|
||||
name = genre?.name,
|
||||
imageUrl = genre?.imageUrl,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun GenreCard(
|
||||
genreId: UUID?,
|
||||
name: String?,
|
||||
imageUrl: String?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val background = rememberIdColor(genre?.id).copy(alpha = .6f)
|
||||
val background = rememberIdColor(genreId).copy(alpha = .6f)
|
||||
Card(
|
||||
modifier =
|
||||
modifier,
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
|
|
@ -63,12 +81,12 @@ fun GenreCard(
|
|||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
) {
|
||||
if (genre?.imageUrl.isNotNullOrBlank()) {
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(genre.imageUrl)
|
||||
.data(imageUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentScale = ContentScale.FillBounds,
|
||||
|
|
@ -88,7 +106,7 @@ fun GenreCard(
|
|||
.background(background),
|
||||
) {
|
||||
Text(
|
||||
text = genre?.name ?: "",
|
||||
text = name ?: "",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
|
|
@ -112,7 +130,6 @@ private fun GenreCardPreview() {
|
|||
UUID.randomUUID(),
|
||||
"Adventure",
|
||||
null,
|
||||
Color.Black,
|
||||
)
|
||||
GenreCard(
|
||||
genre = genre,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ 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.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
|
@ -126,21 +125,7 @@ fun SeasonCard(
|
|||
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 focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||
val width = imageHeight * aspectRationToUse
|
||||
val height = imageWidth * (1f / aspectRationToUse)
|
||||
|
|
@ -212,3 +197,22 @@ fun SeasonCard(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a [androidx.compose.runtime.State] which represents if the item has been focused for a while
|
||||
*/
|
||||
@Composable
|
||||
fun rememberFocusedAfterDelay(interactionSource: MutableInteractionSource): androidx.compose.runtime.State<Boolean> {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val state = remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(focused) {
|
||||
if (!focused) {
|
||||
state.value = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
delay(500L)
|
||||
state.value = true
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
|
||||
/**
|
||||
* Placeholder for [com.github.damontecres.wholphin.ui.cards.ItemRow]. It is [focusable] so it can be scrolled.
|
||||
*/
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun FocusableItemRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
isError: Boolean = false,
|
||||
) = FocusableItemRow(
|
||||
titleContent = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
},
|
||||
subtitleContent = {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun FocusableItemRow(
|
||||
titleContent: @Composable () -> Unit,
|
||||
subtitleContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val background by animateColorAsState(
|
||||
if (focused) {
|
||||
MaterialTheme.colorScheme.border.copy(alpha = .25f)
|
||||
} else {
|
||||
Color.Unspecified
|
||||
},
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.padding(start = 8.dp)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.background(background, shape = RoundedCornerShape(8.dp))
|
||||
.padding(8.dp),
|
||||
) {
|
||||
titleContent.invoke()
|
||||
subtitleContent.invoke()
|
||||
}
|
||||
}
|
||||
|
|
@ -5,12 +5,12 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -41,6 +41,7 @@ import dagger.assisted.Assisted
|
|||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
|
|
@ -102,51 +103,22 @@ class GenreViewModel
|
|||
.execute(api, request)
|
||||
.content.items
|
||||
.map {
|
||||
Genre(it.id, it.name ?: "", null, Color.Black)
|
||||
Genre(it.id, it.name ?: "", null)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
this@GenreViewModel.genres.value = genres
|
||||
loading.value = LoadingState.Success
|
||||
}
|
||||
val genreToUrl = ConcurrentHashMap<UUID, String?>()
|
||||
val semaphore = Semaphore(4)
|
||||
genres
|
||||
.map { genre ->
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
semaphore.withPermit {
|
||||
val item =
|
||||
GetItemsRequestHandler
|
||||
.execute(
|
||||
api,
|
||||
GetItemsRequest(
|
||||
parentId = itemId,
|
||||
recursive = true,
|
||||
limit = 1,
|
||||
sortBy = listOf(ItemSortBy.RANDOM),
|
||||
fields = listOf(ItemFields.GENRES),
|
||||
imageTypes = listOf(ImageType.BACKDROP),
|
||||
imageTypeLimit = 1,
|
||||
includeItemTypes = includeItemTypes,
|
||||
genreIds = listOf(genre.id),
|
||||
enableTotalRecordCount = false,
|
||||
),
|
||||
).content.items
|
||||
.firstOrNull()
|
||||
if (item != null) {
|
||||
genreToUrl[genre.id] =
|
||||
imageUrlService.getItemImageUrl(
|
||||
itemId = item.id,
|
||||
itemType = item.type,
|
||||
seriesId = null,
|
||||
useSeriesForPrimary = true,
|
||||
imageType = ImageType.BACKDROP,
|
||||
imageTags = item.imageTags.orEmpty(),
|
||||
fillWidth = cardWidthPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
val genreToUrl =
|
||||
getGenreImageMap(
|
||||
api = api,
|
||||
scope = viewModelScope,
|
||||
imageUrlService = imageUrlService,
|
||||
genres = genres.map { it.id },
|
||||
parentId = itemId,
|
||||
includeItemTypes = includeItemTypes,
|
||||
cardWidthPx = cardWidthPx,
|
||||
)
|
||||
val genresWithImages =
|
||||
genres.map {
|
||||
it.copy(
|
||||
|
|
@ -171,11 +143,62 @@ class GenreViewModel
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun getGenreImageMap(
|
||||
api: ApiClient,
|
||||
scope: CoroutineScope,
|
||||
imageUrlService: ImageUrlService,
|
||||
genres: List<UUID>,
|
||||
parentId: UUID,
|
||||
includeItemTypes: List<BaseItemKind>?,
|
||||
cardWidthPx: Int?,
|
||||
): Map<UUID, String?> {
|
||||
val genreToUrl = ConcurrentHashMap<UUID, String?>()
|
||||
val semaphore = Semaphore(4)
|
||||
genres
|
||||
.map { genreId ->
|
||||
scope.async(Dispatchers.IO) {
|
||||
semaphore.withPermit {
|
||||
val item =
|
||||
GetItemsRequestHandler
|
||||
.execute(
|
||||
api,
|
||||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
recursive = true,
|
||||
limit = 1,
|
||||
sortBy = listOf(ItemSortBy.RANDOM),
|
||||
fields = listOf(ItemFields.GENRES),
|
||||
imageTypes = listOf(ImageType.BACKDROP),
|
||||
imageTypeLimit = 1,
|
||||
includeItemTypes = includeItemTypes,
|
||||
genreIds = listOf(genreId),
|
||||
enableTotalRecordCount = false,
|
||||
),
|
||||
).content.items
|
||||
.firstOrNull()
|
||||
if (item != null) {
|
||||
genreToUrl[genreId] =
|
||||
imageUrlService.getItemImageUrl(
|
||||
itemId = item.id,
|
||||
itemType = item.type,
|
||||
seriesId = null,
|
||||
useSeriesForPrimary = true,
|
||||
imageType = ImageType.BACKDROP,
|
||||
imageTags = item.imageTags.orEmpty(),
|
||||
fillWidth = cardWidthPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
return genreToUrl
|
||||
}
|
||||
|
||||
@Stable
|
||||
data class Genre(
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
val imageUrl: String?,
|
||||
val color: Color,
|
||||
) : CardGridItem {
|
||||
override val gridId: String get() = id.toString()
|
||||
override val playable: Boolean = false
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
package com.github.damontecres.wholphin.ui.main
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -18,11 +16,13 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
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
|
||||
|
|
@ -34,7 +34,6 @@ import androidx.compose.ui.focus.FocusRequester
|
|||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
|
@ -48,16 +47,19 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
||||
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
|
||||
import com.github.damontecres.wholphin.ui.cards.GenreCard
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.FocusableItemRow
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
|
|
@ -71,6 +73,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
|||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
|
||||
import com.github.damontecres.wholphin.ui.playback.playable
|
||||
import com.github.damontecres.wholphin.ui.playback.scale
|
||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
||||
|
|
@ -94,12 +97,10 @@ fun HomePage(
|
|||
LaunchedEffect(Unit) {
|
||||
viewModel.init()
|
||||
}
|
||||
val loading by viewModel.loadingState.observeAsState(LoadingState.Loading)
|
||||
val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading)
|
||||
val watchingRows by viewModel.watchingRows.observeAsState(listOf())
|
||||
val latestRows by viewModel.latestRows.observeAsState(listOf())
|
||||
|
||||
val homeRows = remember(watchingRows, latestRows) { watchingRows + latestRows }
|
||||
val state by viewModel.state.collectAsState()
|
||||
val loading = state.loadingState
|
||||
val refreshing = state.refreshState
|
||||
val homeRows = state.homeRows
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
|
|
@ -200,6 +201,9 @@ fun HomePageContent(
|
|||
modifier: Modifier = Modifier,
|
||||
onFocusPosition: ((RowColumn) -> Unit)? = null,
|
||||
loadingState: LoadingState? = null,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
takeFocus: Boolean = true,
|
||||
showEmptyRows: Boolean = false,
|
||||
) {
|
||||
var position by rememberPosition()
|
||||
val focusedItem =
|
||||
|
|
@ -207,37 +211,37 @@ fun HomePageContent(
|
|||
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
|
||||
var firstFocused by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(homeRows) {
|
||||
if (!firstFocused && homeRows.isNotEmpty()) {
|
||||
if (position.row >= 0) {
|
||||
val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex)
|
||||
rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
|
||||
firstFocused = true
|
||||
} else {
|
||||
// Waiting for the first home row to load, then focus on it
|
||||
homeRows
|
||||
.indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
|
||||
?.let {
|
||||
rowFocusRequesters[it].tryRequestFocus()
|
||||
firstFocused = true
|
||||
delay(50)
|
||||
listState.scrollToItem(it)
|
||||
}
|
||||
if (takeFocus) {
|
||||
LaunchedEffect(homeRows) {
|
||||
if (!firstFocused && homeRows.isNotEmpty()) {
|
||||
if (position.row >= 0) {
|
||||
val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex)
|
||||
rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
|
||||
firstFocused = true
|
||||
} else {
|
||||
// Waiting for the first home row to load, then focus on it
|
||||
homeRows
|
||||
.indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
|
||||
?.let {
|
||||
rowFocusRequesters[it].tryRequestFocus()
|
||||
firstFocused = true
|
||||
delay(50)
|
||||
listState.scrollToItem(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(position) {
|
||||
if (position.row >= 0) {
|
||||
listState.animateScrollToItem(position.row)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(onUpdateBackdrop, focusedItem) {
|
||||
focusedItem?.let { onUpdateBackdrop.invoke(it) }
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val spaceAbovePx =
|
||||
with(density) {
|
||||
// The size of the row titles & spacing
|
||||
50.dp.toPx()
|
||||
}
|
||||
Box(modifier = modifier) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
HomePageHeader(
|
||||
|
|
@ -247,6 +251,12 @@ fun HomePageContent(
|
|||
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
|
||||
.fillMaxHeight(.33f),
|
||||
)
|
||||
val density = LocalDensity.current
|
||||
val spaceAbovePx =
|
||||
with(density) {
|
||||
// The size of the row titles & spacing
|
||||
50.dp.toPx()
|
||||
}
|
||||
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
|
||||
CompositionLocalProvider(
|
||||
LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
|
||||
|
|
@ -263,61 +273,32 @@ fun HomePageContent(
|
|||
.focusRestorer(),
|
||||
) {
|
||||
itemsIndexed(homeRows) { rowIndex, row ->
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) {
|
||||
CompositionLocalProvider(
|
||||
LocalBringIntoViewSpec provides defaultBringIntoViewSpec,
|
||||
) {
|
||||
when (val r = row) {
|
||||
is HomeRowLoadingState.Loading,
|
||||
is HomeRowLoadingState.Pending,
|
||||
-> {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
FocusableItemRow(
|
||||
title = r.title,
|
||||
subtitle = stringResource(R.string.loading),
|
||||
modifier = Modifier.animateItem(),
|
||||
) {
|
||||
Text(
|
||||
text = r.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.loading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowLoadingState.Error -> {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged {
|
||||
focused = it.isFocused
|
||||
}.focusable()
|
||||
.background(
|
||||
if (focused) {
|
||||
// Just so the user can tell it has focus
|
||||
MaterialTheme.colorScheme.border.copy(alpha = .25f)
|
||||
} else {
|
||||
Color.Unspecified
|
||||
},
|
||||
).animateItem(),
|
||||
) {
|
||||
Text(
|
||||
text = r.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
text = r.localizedMessage,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
FocusableItemRow(
|
||||
title = r.title,
|
||||
subtitle = r.localizedMessage,
|
||||
isError = true,
|
||||
modifier = Modifier.animateItem(),
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowLoadingState.Success -> {
|
||||
if (row.items.isNotEmpty()) {
|
||||
val viewOptions = row.viewOptions
|
||||
ItemRow(
|
||||
title = row.title,
|
||||
items = row.items,
|
||||
|
|
@ -336,26 +317,20 @@ fun HomePageContent(
|
|||
.focusGroup()
|
||||
.focusRequester(rowFocusRequesters[rowIndex])
|
||||
.animateItem(),
|
||||
horizontalPadding = viewOptions.spacing.dp,
|
||||
cardContent = { index, item, cardModifier, onClick, onLongClick ->
|
||||
BannerCard(
|
||||
name = item?.data?.seriesName ?: item?.name,
|
||||
HomePageCardContent(
|
||||
index = index,
|
||||
item = item,
|
||||
aspectRatio = AspectRatios.TALL,
|
||||
cornerText = item?.ui?.episodeUnplayedCornerText,
|
||||
played = item?.data?.userData?.played ?: false,
|
||||
favorite = item?.favorite ?: false,
|
||||
playPercent =
|
||||
item?.data?.userData?.playedPercentage
|
||||
?: 0.0,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
viewOptions = viewOptions,
|
||||
modifier =
|
||||
cardModifier
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
position =
|
||||
RowColumn(rowIndex, index)
|
||||
// item?.let(onUpdateBackdrop)
|
||||
}
|
||||
if (it.isFocused && onFocusPosition != null) {
|
||||
val nonEmptyRowBefore =
|
||||
|
|
@ -382,11 +357,15 @@ fun HomePageContent(
|
|||
}
|
||||
return@onKeyEvent false
|
||||
},
|
||||
interactionSource = null,
|
||||
cardHeight = Cards.height2x3,
|
||||
)
|
||||
},
|
||||
)
|
||||
} else if (showEmptyRows) {
|
||||
FocusableItemRow(
|
||||
title = r.title,
|
||||
subtitle = stringResource(R.string.no_results),
|
||||
modifier = Modifier.animateItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -427,7 +406,7 @@ fun HomePageHeader(
|
|||
subtitle = if (isEpisode) dto?.name else null,
|
||||
overview = dto?.overview,
|
||||
overviewTwoLines = isEpisode,
|
||||
quickDetails = item?.ui?.quickDetails,
|
||||
quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""),
|
||||
timeRemaining = item?.timeRemainingOrRuntime,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
|
@ -488,3 +467,92 @@ fun HomePageHeader(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomePageCardContent(
|
||||
index: Int,
|
||||
item: BaseItem?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
viewOptions: HomeRowViewOptions,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
when (item?.type) {
|
||||
BaseItemKind.GENRE -> {
|
||||
GenreCard(
|
||||
genreId = item.id,
|
||||
name = item.name,
|
||||
imageUrl = item.imageUrlOverride,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier.height(viewOptions.heightDp.dp),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val imageType =
|
||||
remember(item, viewOptions) {
|
||||
if (item?.type == BaseItemKind.EPISODE) {
|
||||
viewOptions.episodeImageType.imageType
|
||||
} else {
|
||||
viewOptions.imageType.imageType
|
||||
}
|
||||
}
|
||||
val ratio =
|
||||
remember(item, viewOptions) {
|
||||
if (item?.type == BaseItemKind.EPISODE) {
|
||||
viewOptions.episodeAspectRatio.ratio
|
||||
} else {
|
||||
viewOptions.aspectRatio.ratio
|
||||
}
|
||||
}
|
||||
val scale =
|
||||
remember(item, viewOptions) {
|
||||
if (item?.type == BaseItemKind.EPISODE) {
|
||||
viewOptions.episodeContentScale.scale
|
||||
} else {
|
||||
viewOptions.contentScale.scale
|
||||
}
|
||||
}
|
||||
if (viewOptions.showTitles) {
|
||||
BannerCardWithTitle(
|
||||
title = item?.title,
|
||||
subtitle = item?.subtitle,
|
||||
item = item,
|
||||
aspectRatio = ratio,
|
||||
imageType = imageType,
|
||||
imageContentScale = scale,
|
||||
cornerText = item?.ui?.episodeUnplayedCornerText,
|
||||
played = item?.data?.userData?.played ?: false,
|
||||
favorite = item?.favorite ?: false,
|
||||
playPercent =
|
||||
item?.data?.userData?.playedPercentage
|
||||
?: 0.0,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier,
|
||||
cardHeight = viewOptions.heightDp.dp,
|
||||
)
|
||||
} else {
|
||||
BannerCard(
|
||||
name = item?.data?.seriesName ?: item?.name,
|
||||
item = item,
|
||||
aspectRatio = ratio,
|
||||
imageType = imageType,
|
||||
imageContentScale = scale,
|
||||
cornerText = item?.ui?.episodeUnplayedCornerText,
|
||||
played = item?.data?.userData?.played ?: false,
|
||||
favorite = item?.favorite ?: false,
|
||||
playPercent =
|
||||
item?.data?.userData?.playedPercentage
|
||||
?: 0.0,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier,
|
||||
interactionSource = null,
|
||||
cardHeight = viewOptions.heightDp.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,40 @@
|
|||
package com.github.damontecres.wholphin.ui.main
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
|
||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
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 kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
|
@ -41,115 +44,120 @@ class HomeViewModel
|
|||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
val api: ApiClient,
|
||||
val navigationManager: NavigationManager,
|
||||
val serverRepository: ServerRepository,
|
||||
val navDrawerItemRepository: NavDrawerItemRepository,
|
||||
val mediaReportService: MediaReportService,
|
||||
private val homeSettingsService: HomeSettingsService,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val datePlayedService: DatePlayedService,
|
||||
private val latestNextUpService: LatestNextUpService,
|
||||
private val backdropService: BackdropService,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
) : ViewModel() {
|
||||
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||
val watchingRows = MutableLiveData<List<HomeRowLoadingState>>(listOf())
|
||||
val latestRows = MutableLiveData<List<HomeRowLoadingState>>(listOf())
|
||||
|
||||
private lateinit var preferences: UserPreferences
|
||||
private val _state = MutableStateFlow(HomeState.EMPTY)
|
||||
val state: StateFlow<HomeState> = _state
|
||||
|
||||
init {
|
||||
datePlayedService.invalidateAll()
|
||||
init()
|
||||
// init()
|
||||
}
|
||||
|
||||
fun init() {
|
||||
viewModelScope.launch(
|
||||
Dispatchers.IO +
|
||||
LoadingExceptionHandler(
|
||||
loadingState,
|
||||
"Error loading home page",
|
||||
),
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
Timber.d("init HomeViewModel")
|
||||
val reload = loadingState.value != LoadingState.Success
|
||||
if (reload) {
|
||||
loadingState.setValueOnMain(LoadingState.Loading)
|
||||
}
|
||||
refreshState.setValueOnMain(LoadingState.Loading)
|
||||
this@HomeViewModel.preferences = userPreferencesService.getCurrent()
|
||||
val prefs = preferences.appPreferences.homePagePreferences
|
||||
val limit = prefs.maxItemsPerRow
|
||||
if (reload) {
|
||||
backdropService.clearBackdrop()
|
||||
}
|
||||
try {
|
||||
val preferences = userPreferencesService.getCurrent()
|
||||
val prefs = preferences.appPreferences.homePagePreferences
|
||||
|
||||
val navDrawerItems =
|
||||
navDrawerItemRepository
|
||||
.getNavDrawerItems()
|
||||
val libraries =
|
||||
navDrawerItems
|
||||
.filter { it is ServerNavDrawerItem }
|
||||
.map {
|
||||
it as ServerNavDrawerItem
|
||||
Library(it.itemId, it.name, it.type)
|
||||
}
|
||||
serverRepository.currentUserDto.value?.let { userDto ->
|
||||
val includedIds =
|
||||
navDrawerItemRepository
|
||||
.getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
|
||||
.filter { it is ServerNavDrawerItem }
|
||||
.map { (it as ServerNavDrawerItem).itemId }
|
||||
val resume = latestNextUpService.getResume(userDto.id, limit, true)
|
||||
val nextUp =
|
||||
latestNextUpService.getNextUp(
|
||||
userDto.id,
|
||||
limit,
|
||||
prefs.enableRewatchingNextUp,
|
||||
false,
|
||||
prefs.maxDaysNextUp,
|
||||
)
|
||||
val watching =
|
||||
buildList {
|
||||
if (prefs.combineContinueNext) {
|
||||
val items = latestNextUpService.buildCombined(resume, nextUp)
|
||||
add(
|
||||
HomeRowLoadingState.Success(
|
||||
title = context.getString(R.string.continue_watching),
|
||||
items = items,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
if (resume.isNotEmpty()) {
|
||||
add(
|
||||
HomeRowLoadingState.Success(
|
||||
title = context.getString(R.string.continue_watching),
|
||||
items = resume,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (nextUp.isNotEmpty()) {
|
||||
add(
|
||||
HomeRowLoadingState.Success(
|
||||
title = context.getString(R.string.next_up),
|
||||
items = nextUp,
|
||||
),
|
||||
)
|
||||
val settings =
|
||||
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
|
||||
val state = state.value
|
||||
|
||||
// Refreshing if a load has already occurred and the rows haven't significantly changed
|
||||
val refresh =
|
||||
state.loadingState == LoadingState.Success && state.settings == settings
|
||||
|
||||
val semaphore = Semaphore(4)
|
||||
|
||||
val watchingRowIndexes =
|
||||
settings.rows
|
||||
.mapIndexedNotNull { index, row ->
|
||||
if (isWatchingRow(row.config)) index else null
|
||||
}
|
||||
val deferred =
|
||||
settings.rows
|
||||
// Load the watching rows first
|
||||
.sortedByDescending { isWatchingRow(it.config) }
|
||||
.map { row ->
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
semaphore.withPermit {
|
||||
Timber.v("Fetching row: %s", row)
|
||||
try {
|
||||
homeSettingsService.fetchDataForRow(
|
||||
row = row.config,
|
||||
scope = viewModelScope,
|
||||
prefs = prefs,
|
||||
userDto = userDto,
|
||||
libraries = libraries,
|
||||
limit = prefs.maxItemsPerRow,
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error on row %s", row)
|
||||
HomeRowLoadingState.Error(row.title, exception = ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val latest = latestNextUpService.getLatest(userDto, limit, includedIds)
|
||||
val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) }
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
this@HomeViewModel.watchingRows.value = watching
|
||||
if (reload) {
|
||||
this@HomeViewModel.latestRows.value = pendingLatest
|
||||
if (refresh && state.homeRows.isNotEmpty() && watchingRowIndexes.isNotEmpty()) {
|
||||
// Replace watching rows first
|
||||
Timber.v("Refreshing rows: %s", watchingRowIndexes)
|
||||
val rows =
|
||||
deferred
|
||||
.filterIndexed { index, _ -> index in watchingRowIndexes }
|
||||
.awaitAll()
|
||||
_state.update {
|
||||
val newRows =
|
||||
it.homeRows.toMutableList().apply {
|
||||
rows.forEachIndexed { index, row ->
|
||||
set(watchingRowIndexes[index], row)
|
||||
}
|
||||
}
|
||||
it.copy(
|
||||
loadingState = LoadingState.Success,
|
||||
homeRows = newRows,
|
||||
)
|
||||
}
|
||||
loadingState.value = LoadingState.Success
|
||||
}
|
||||
refreshState.setValueOnMain(LoadingState.Success)
|
||||
val loadedLatest = latestNextUpService.loadLatest(latest)
|
||||
this@HomeViewModel.latestRows.setValueOnMain(loadedLatest)
|
||||
val rows = deferred.awaitAll()
|
||||
Timber.v("Got all rows")
|
||||
_state.update {
|
||||
it.copy(
|
||||
loadingState = LoadingState.Success,
|
||||
refreshState = LoadingState.Success,
|
||||
homeRows = rows,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
if (!reload) {
|
||||
loadingState.setValueOnMain(LoadingState.Error(ex))
|
||||
} else {
|
||||
Timber.e(ex, "Exception during home page loading")
|
||||
if (state.value.loadingState == LoadingState.Success) {
|
||||
showToast(context, "Error refreshing home: ${ex.localizedMessage}")
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(loadingState = LoadingState.Error(ex))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -182,16 +190,27 @@ class HomeViewModel
|
|||
}
|
||||
}
|
||||
|
||||
val supportedLatestCollectionTypes =
|
||||
setOf(
|
||||
CollectionType.MOVIES,
|
||||
CollectionType.TVSHOWS,
|
||||
CollectionType.HOMEVIDEOS,
|
||||
// Exclude Live TV because a recording folder view will be used instead
|
||||
null, // Recordings & mixed collection types
|
||||
)
|
||||
data class HomeState(
|
||||
val loadingState: LoadingState,
|
||||
val refreshState: LoadingState,
|
||||
val homeRows: List<HomeRowLoadingState>,
|
||||
val settings: HomePageResolvedSettings,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY =
|
||||
HomeState(
|
||||
LoadingState.Pending,
|
||||
LoadingState.Pending,
|
||||
listOf(),
|
||||
HomePageResolvedSettings.EMPTY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class LatestData(
|
||||
val title: String,
|
||||
val request: GetLatestMediaRequest,
|
||||
)
|
||||
/**
|
||||
* Whether a row is a "is watching" type
|
||||
*/
|
||||
private fun isWatchingRow(row: HomeRowConfig) =
|
||||
row is HomeRowConfig.ContinueWatching ||
|
||||
row is HomeRowConfig.NextUp ||
|
||||
row is HomeRowConfig.ContinueWatchingCombined
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.services.SuggestionsWorker
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
||||
@Composable
|
||||
fun HomeLibraryRowTypeList(
|
||||
library: Library,
|
||||
onClick: (LibraryRowType) -> Unit,
|
||||
modifier: Modifier,
|
||||
firstFocus: FocusRequester = remember { FocusRequester() },
|
||||
) {
|
||||
val items = remember(library) { getSupportedRowTypes(library) }
|
||||
LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
|
||||
Column(modifier = modifier) {
|
||||
TitleText(stringResource(R.string.add_row_for, library.name))
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxHeight()
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
itemsIndexed(items) { index, rowType ->
|
||||
ListItem(
|
||||
selected = false,
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringResource(rowType.stringId),
|
||||
)
|
||||
},
|
||||
onClick = { onClick.invoke(rowType) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSupportedRowTypes(library: Library): List<LibraryRowType> {
|
||||
val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
|
||||
return if (itemKind != null) {
|
||||
LibraryRowType.entries
|
||||
} else {
|
||||
LibraryRowType.entries.toMutableList().apply { remove(LibraryRowType.SUGGESTIONS) }
|
||||
}
|
||||
}
|
||||
|
||||
enum class LibraryRowType(
|
||||
@param:StringRes val stringId: Int,
|
||||
) {
|
||||
RECENTLY_ADDED(R.string.recently_added),
|
||||
RECENTLY_RELEASED(R.string.recently_released),
|
||||
SUGGESTIONS(R.string.suggestions),
|
||||
GENRES(R.string.genres),
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.preferences.PrefContentScale
|
||||
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
||||
data class HomeRowPresets(
|
||||
val continueWatching: HomeRowViewOptions,
|
||||
val movieLibrary: HomeRowViewOptions,
|
||||
val tvLibrary: HomeRowViewOptions,
|
||||
val videoLibrary: HomeRowViewOptions,
|
||||
val photoLibrary: HomeRowViewOptions,
|
||||
val playlist: HomeRowViewOptions,
|
||||
val genreSize: Int,
|
||||
) {
|
||||
fun getByCollectionType(collectionType: CollectionType): HomeRowViewOptions =
|
||||
when (collectionType) {
|
||||
CollectionType.MOVIES -> movieLibrary
|
||||
|
||||
CollectionType.TVSHOWS -> tvLibrary
|
||||
|
||||
CollectionType.MUSICVIDEOS -> videoLibrary
|
||||
|
||||
CollectionType.TRAILERS -> videoLibrary
|
||||
|
||||
CollectionType.HOMEVIDEOS -> videoLibrary
|
||||
|
||||
CollectionType.BOXSETS -> movieLibrary
|
||||
|
||||
CollectionType.PHOTOS -> photoLibrary
|
||||
|
||||
CollectionType.UNKNOWN,
|
||||
CollectionType.MUSIC,
|
||||
CollectionType.BOOKS,
|
||||
CollectionType.LIVETV,
|
||||
CollectionType.PLAYLISTS,
|
||||
CollectionType.FOLDERS,
|
||||
-> HomeRowViewOptions()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val WholphinDefault by lazy {
|
||||
HomeRowPresets(
|
||||
continueWatching = HomeRowViewOptions(),
|
||||
movieLibrary = HomeRowViewOptions(),
|
||||
tvLibrary = HomeRowViewOptions(),
|
||||
videoLibrary =
|
||||
HomeRowViewOptions(
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
),
|
||||
photoLibrary =
|
||||
HomeRowViewOptions(
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.CROP,
|
||||
),
|
||||
playlist =
|
||||
HomeRowViewOptions(
|
||||
aspectRatio = AspectRatio.SQUARE,
|
||||
contentScale = PrefContentScale.FIT,
|
||||
),
|
||||
genreSize = Cards.HEIGHT_2X3_DP,
|
||||
)
|
||||
}
|
||||
|
||||
val WholphinCompact by lazy {
|
||||
val height = 148
|
||||
val epHeight = 100
|
||||
HomeRowPresets(
|
||||
continueWatching =
|
||||
HomeRowViewOptions(
|
||||
heightDp = height,
|
||||
),
|
||||
movieLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = height,
|
||||
),
|
||||
tvLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = height,
|
||||
),
|
||||
videoLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
),
|
||||
photoLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.CROP,
|
||||
),
|
||||
playlist =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
aspectRatio = AspectRatio.SQUARE,
|
||||
contentScale = PrefContentScale.FIT,
|
||||
),
|
||||
genreSize = epHeight,
|
||||
)
|
||||
}
|
||||
|
||||
val Thumbnails by lazy {
|
||||
val height = 148
|
||||
val epHeight = 100
|
||||
HomeRowPresets(
|
||||
continueWatching =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
imageType = ViewOptionImageType.THUMB,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
episodeImageType = ViewOptionImageType.THUMB,
|
||||
episodeAspectRatio = AspectRatio.WIDE,
|
||||
),
|
||||
movieLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = height,
|
||||
),
|
||||
tvLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = height,
|
||||
),
|
||||
videoLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
),
|
||||
photoLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.CROP,
|
||||
),
|
||||
playlist =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
aspectRatio = AspectRatio.SQUARE,
|
||||
contentScale = PrefContentScale.FIT,
|
||||
),
|
||||
genreSize = epHeight,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeRowPresetsContent(
|
||||
onApply: (HomeRowPresets) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val presets =
|
||||
remember {
|
||||
listOf(
|
||||
"Wholphin Default",
|
||||
"Wholphin Compact",
|
||||
"Thumbnails",
|
||||
)
|
||||
}
|
||||
val focusRequesters = remember { List(presets.size) { FocusRequester() } }
|
||||
LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() }
|
||||
Column(modifier = modifier) {
|
||||
TitleText(stringResource(R.string.display_presets))
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxHeight()
|
||||
.focusRestorer(focusRequesters[0]),
|
||||
) {
|
||||
itemsIndexed(presets) { index, title ->
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = title,
|
||||
onClick = {
|
||||
when (index) {
|
||||
0 -> onApply.invoke(HomeRowPresets.WholphinDefault)
|
||||
1 -> onApply.invoke(HomeRowPresets.WholphinCompact)
|
||||
2 -> onApply.invoke(HomeRowPresets.Thumbnails)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[index]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.preferences.AppChoicePreference
|
||||
import com.github.damontecres.wholphin.preferences.AppClickablePreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppSliderPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
|
||||
import com.github.damontecres.wholphin.preferences.PrefContentScale
|
||||
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
||||
@Composable
|
||||
fun HomeRowSettings(
|
||||
title: String,
|
||||
preferenceOptions: List<PreferenceGroup<HomeRowViewOptions>>,
|
||||
viewOptions: HomeRowViewOptions,
|
||||
onViewOptionsChange: (HomeRowViewOptions) -> Unit,
|
||||
onApplyApplyAll: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
) {
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
|
||||
Column(modifier = modifier) {
|
||||
TitleText(title)
|
||||
LazyColumn {
|
||||
preferenceOptions.forEachIndexed { groupIndex, prefGroup ->
|
||||
if (preferenceOptions.size > 1) {
|
||||
item {
|
||||
TitleText(stringResource(prefGroup.title))
|
||||
}
|
||||
}
|
||||
itemsIndexed(prefGroup.preferences) { index, pref ->
|
||||
pref as AppPreference<HomeRowViewOptions, Any>
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val value = pref.getter.invoke(viewOptions)
|
||||
ComposablePreference(
|
||||
preference = pref,
|
||||
value = value,
|
||||
onNavigate = {},
|
||||
onValueChange = { newValue ->
|
||||
onViewOptionsChange.invoke(pref.setter(viewOptions, newValue))
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
onClickPreference = { pref ->
|
||||
when (pref) {
|
||||
Options.ViewOptionsReset -> {
|
||||
onViewOptionsChange.invoke(defaultViewOptions)
|
||||
}
|
||||
|
||||
Options.ViewOptionsApplyAll -> {
|
||||
onApplyApplyAll.invoke()
|
||||
}
|
||||
|
||||
Options.ViewOptionsUseThumb -> {
|
||||
onViewOptionsChange.invoke(
|
||||
viewOptions.copy(
|
||||
heightDp = Cards.HEIGHT_EPISODE,
|
||||
spacing = 20,
|
||||
imageType = ViewOptionImageType.THUMB,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.FIT,
|
||||
episodeImageType = ViewOptionImageType.THUMB,
|
||||
episodeAspectRatio = AspectRatio.WIDE,
|
||||
episodeContentScale = PrefContentScale.FIT,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.ifElse(
|
||||
groupIndex == 0 && index == 0,
|
||||
Modifier.focusRequester(firstFocus),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal object Options {
|
||||
val ViewOptionsCardHeight =
|
||||
AppSliderPreference<HomeRowViewOptions>(
|
||||
title = R.string.height,
|
||||
defaultValue = Cards.HEIGHT_2X3_DP.toLong(),
|
||||
min = 64L,
|
||||
max = Cards.HEIGHT_2X3_DP + 64L,
|
||||
interval = 4,
|
||||
getter = { it.heightDp.toLong() },
|
||||
setter = { prefs, value -> prefs.copy(heightDp = value.toInt()) },
|
||||
)
|
||||
val ViewOptionsSpacing =
|
||||
AppSliderPreference<HomeRowViewOptions>(
|
||||
title = R.string.spacing,
|
||||
defaultValue = 16,
|
||||
min = 0,
|
||||
max = 32,
|
||||
interval = 2,
|
||||
getter = { it.spacing.toLong() },
|
||||
setter = { prefs, value -> prefs.copy(spacing = value.toInt()) },
|
||||
)
|
||||
|
||||
val ViewOptionsContentScale =
|
||||
AppChoicePreference<HomeRowViewOptions, PrefContentScale>(
|
||||
title = R.string.global_content_scale,
|
||||
defaultValue = PrefContentScale.FIT,
|
||||
displayValues = R.array.content_scale,
|
||||
getter = { it.contentScale },
|
||||
setter = { viewOptions, value -> viewOptions.copy(contentScale = value) },
|
||||
indexToValue = { PrefContentScale.forNumber(it) },
|
||||
valueToIndex = { it.number },
|
||||
)
|
||||
|
||||
val ViewOptionsAspectRatio =
|
||||
AppChoicePreference<HomeRowViewOptions, AspectRatio>(
|
||||
title = R.string.aspect_ratio,
|
||||
defaultValue = AspectRatio.TALL,
|
||||
displayValues = R.array.aspect_ratios,
|
||||
getter = { it.aspectRatio },
|
||||
setter = { viewOptions, value -> viewOptions.copy(aspectRatio = value) },
|
||||
indexToValue = { AspectRatio.entries[it] },
|
||||
valueToIndex = { it.ordinal },
|
||||
)
|
||||
|
||||
val ViewOptionsShowTitles =
|
||||
AppSwitchPreference<HomeRowViewOptions>(
|
||||
title = R.string.show_titles,
|
||||
defaultValue = true,
|
||||
getter = { it.showTitles },
|
||||
setter = { vo, value -> vo.copy(showTitles = value) },
|
||||
)
|
||||
|
||||
val ViewOptionsUseSeries =
|
||||
AppSwitchPreference<HomeRowViewOptions>(
|
||||
title = R.string.use_series,
|
||||
defaultValue = true,
|
||||
getter = { it.useSeries },
|
||||
setter = { vo, value -> vo.copy(useSeries = value) },
|
||||
)
|
||||
|
||||
val ViewOptionsImageType =
|
||||
AppChoicePreference<HomeRowViewOptions, ViewOptionImageType>(
|
||||
title = R.string.image_type,
|
||||
defaultValue = ViewOptionImageType.PRIMARY,
|
||||
displayValues = R.array.image_types,
|
||||
getter = { it.imageType },
|
||||
setter = { viewOptions, value ->
|
||||
val aspectRatio =
|
||||
when (value) {
|
||||
ViewOptionImageType.PRIMARY -> AspectRatio.TALL
|
||||
ViewOptionImageType.THUMB -> AspectRatio.WIDE
|
||||
}
|
||||
viewOptions.copy(imageType = value, aspectRatio = aspectRatio)
|
||||
},
|
||||
indexToValue = { ViewOptionImageType.entries[it] },
|
||||
valueToIndex = { it.ordinal },
|
||||
)
|
||||
|
||||
val ViewOptionsApplyAll =
|
||||
AppClickablePreference<HomeRowViewOptions>(
|
||||
title = R.string.apply_all_rows,
|
||||
)
|
||||
|
||||
val ViewOptionsReset =
|
||||
AppClickablePreference<HomeRowViewOptions>(
|
||||
title = R.string.reset,
|
||||
)
|
||||
|
||||
val ViewOptionsUseThumb =
|
||||
AppClickablePreference<HomeRowViewOptions>(
|
||||
title = R.string.use_thumb_images,
|
||||
)
|
||||
|
||||
val ViewOptionsEpisodeContentScale =
|
||||
AppChoicePreference<HomeRowViewOptions, PrefContentScale>(
|
||||
title = R.string.global_content_scale,
|
||||
defaultValue = PrefContentScale.FIT,
|
||||
displayValues = R.array.content_scale,
|
||||
getter = { it.contentScale },
|
||||
setter = { viewOptions, value -> viewOptions.copy(episodeContentScale = value) },
|
||||
indexToValue = { PrefContentScale.forNumber(it) },
|
||||
valueToIndex = { it.number },
|
||||
)
|
||||
|
||||
val ViewOptionsEpisodeAspectRatio =
|
||||
AppChoicePreference<HomeRowViewOptions, AspectRatio>(
|
||||
title = R.string.aspect_ratio,
|
||||
defaultValue = AspectRatio.TALL,
|
||||
displayValues = R.array.aspect_ratios,
|
||||
getter = { it.episodeAspectRatio },
|
||||
setter = { viewOptions, value -> viewOptions.copy(episodeAspectRatio = value) },
|
||||
indexToValue = { AspectRatio.entries[it] },
|
||||
valueToIndex = { it.ordinal },
|
||||
)
|
||||
|
||||
val ViewOptionsEpisodeImageType =
|
||||
AppChoicePreference<HomeRowViewOptions, ViewOptionImageType>(
|
||||
title = R.string.image_type,
|
||||
defaultValue = ViewOptionImageType.PRIMARY,
|
||||
displayValues = R.array.image_types,
|
||||
getter = { it.imageType },
|
||||
setter = { viewOptions, value ->
|
||||
val aspectRatio =
|
||||
when (value) {
|
||||
ViewOptionImageType.PRIMARY -> AspectRatio.TALL
|
||||
ViewOptionImageType.THUMB -> AspectRatio.WIDE
|
||||
}
|
||||
viewOptions.copy(episodeImageType = value, episodeAspectRatio = aspectRatio)
|
||||
},
|
||||
indexToValue = { ViewOptionImageType.entries[it] },
|
||||
valueToIndex = { it.ordinal },
|
||||
)
|
||||
|
||||
val OPTIONS =
|
||||
listOf(
|
||||
PreferenceGroup(
|
||||
title = R.string.general,
|
||||
preferences =
|
||||
listOf(
|
||||
ViewOptionsCardHeight,
|
||||
ViewOptionsSpacing,
|
||||
ViewOptionsShowTitles,
|
||||
ViewOptionsImageType,
|
||||
ViewOptionsAspectRatio,
|
||||
ViewOptionsContentScale,
|
||||
ViewOptionsUseSeries,
|
||||
),
|
||||
),
|
||||
PreferenceGroup(
|
||||
title = R.string.more,
|
||||
preferences =
|
||||
listOf(
|
||||
// ViewOptionsApplyAll,
|
||||
ViewOptionsUseThumb,
|
||||
ViewOptionsReset,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val OPTIONS_EPISODES =
|
||||
listOf(
|
||||
PreferenceGroup(
|
||||
title = R.string.general,
|
||||
preferences =
|
||||
listOf(
|
||||
ViewOptionsCardHeight,
|
||||
ViewOptionsSpacing,
|
||||
ViewOptionsShowTitles,
|
||||
ViewOptionsImageType,
|
||||
ViewOptionsAspectRatio,
|
||||
ViewOptionsContentScale,
|
||||
),
|
||||
),
|
||||
PreferenceGroup(
|
||||
title = R.string.for_episodes,
|
||||
preferences =
|
||||
listOf(
|
||||
ViewOptionsUseSeries,
|
||||
ViewOptionsEpisodeImageType,
|
||||
ViewOptionsEpisodeAspectRatio,
|
||||
ViewOptionsEpisodeContentScale,
|
||||
),
|
||||
),
|
||||
PreferenceGroup(
|
||||
title = R.string.more,
|
||||
preferences =
|
||||
listOf(
|
||||
ViewOptionsUseThumb,
|
||||
ViewOptionsReset,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val GENRE_OPTIONS =
|
||||
listOf(
|
||||
PreferenceGroup(
|
||||
title = R.string.general,
|
||||
preferences =
|
||||
listOf(
|
||||
ViewOptionsCardHeight,
|
||||
ViewOptionsSpacing,
|
||||
ViewOptionsReset,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
|
||||
@Composable
|
||||
fun HomeSettingsAddRow(
|
||||
libraries: List<Library>,
|
||||
showDiscover: Boolean,
|
||||
onClick: (Library) -> Unit,
|
||||
onClickMeta: (MetaRowType) -> Unit,
|
||||
modifier: Modifier,
|
||||
firstFocus: FocusRequester = remember { FocusRequester() },
|
||||
) {
|
||||
// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
|
||||
Column(modifier = modifier) {
|
||||
TitleText(stringResource(R.string.add_row))
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxHeight()
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
itemsIndexed(
|
||||
listOf(
|
||||
MetaRowType.CONTINUE_WATCHING,
|
||||
MetaRowType.NEXT_UP,
|
||||
MetaRowType.COMBINED_CONTINUE_WATCHING,
|
||||
),
|
||||
) { index, type ->
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(type.stringId),
|
||||
onClick = { onClickMeta.invoke(type) },
|
||||
modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
|
||||
)
|
||||
}
|
||||
item {
|
||||
TitleText(stringResource(R.string.library))
|
||||
HorizontalDivider()
|
||||
}
|
||||
itemsIndexed(libraries) { index, library ->
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = library.name,
|
||||
onClick = { onClick.invoke(library) },
|
||||
modifier = Modifier, // .ifElse(index == 0, Modifier.focusRequester(firstFocus)),
|
||||
)
|
||||
}
|
||||
item {
|
||||
TitleText(stringResource(R.string.more))
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(MetaRowType.FAVORITES.stringId),
|
||||
onClick = { onClickMeta.invoke(MetaRowType.FAVORITES) },
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
if (showDiscover) {
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(MetaRowType.DISCOVER.stringId),
|
||||
onClick = { onClickMeta.invoke(MetaRowType.DISCOVER) },
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class MetaRowType(
|
||||
@param:StringRes val stringId: Int,
|
||||
) {
|
||||
CONTINUE_WATCHING(R.string.continue_watching),
|
||||
NEXT_UP(R.string.next_up),
|
||||
COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next),
|
||||
FAVORITES(R.string.favorites),
|
||||
DISCOVER(R.string.discover),
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Tracking the pages for selecting and configuring rows
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface HomeSettingsDestination : NavKey {
|
||||
@Serializable
|
||||
data object RowList : HomeSettingsDestination
|
||||
|
||||
@Serializable
|
||||
data object AddRow : HomeSettingsDestination
|
||||
|
||||
@Serializable
|
||||
data class ChooseRowType(
|
||||
val library: Library,
|
||||
) : HomeSettingsDestination
|
||||
|
||||
@Serializable
|
||||
data class RowSettings(
|
||||
val rowId: Int,
|
||||
) : HomeSettingsDestination
|
||||
|
||||
@Serializable
|
||||
data object ChooseFavorite : HomeSettingsDestination
|
||||
|
||||
@Serializable
|
||||
data object ChooseDiscover : HomeSettingsDestination
|
||||
|
||||
@Serializable
|
||||
data object GlobalSettings : HomeSettingsDestination
|
||||
|
||||
@Serializable
|
||||
data object Presets : HomeSettingsDestination
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
||||
@Composable
|
||||
fun HomeSettingsFavoriteList(
|
||||
onClick: (BaseItemKind) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
firstFocus: FocusRequester = remember { FocusRequester() },
|
||||
) {
|
||||
LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
|
||||
Column(modifier = modifier) {
|
||||
TitleText(
|
||||
stringResource(R.string.add_row_for, stringResource(R.string.favorites)),
|
||||
)
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxHeight()
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
itemsIndexed(favoriteOptionsList) { index, type ->
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(favoriteOptions[type]!!),
|
||||
onClick = { onClick.invoke(type) },
|
||||
modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val favoriteOptions by lazy {
|
||||
mapOf(
|
||||
BaseItemKind.MOVIE to R.string.movies,
|
||||
BaseItemKind.SERIES to R.string.tv_shows,
|
||||
BaseItemKind.EPISODE to R.string.episodes,
|
||||
BaseItemKind.VIDEO to R.string.videos,
|
||||
BaseItemKind.PLAYLIST to R.string.playlists,
|
||||
BaseItemKind.PERSON to R.string.people,
|
||||
)
|
||||
}
|
||||
val favoriteOptionsList by lazy { favoriteOptions.keys.toList() }
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
||||
@Composable
|
||||
fun HomeSettingsGlobal(
|
||||
preferences: AppPreferences,
|
||||
onPreferenceChange: (AppPreferences) -> Unit,
|
||||
onClickResize: (Int) -> Unit,
|
||||
onClickSave: () -> Unit,
|
||||
onClickLoad: () -> Unit,
|
||||
onClickLoadWeb: () -> Unit,
|
||||
onClickReset: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val firstFocus: FocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
HorizontalDivider()
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxHeight()
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
item {
|
||||
ComposablePreference(
|
||||
preference = AppPreference.HomePageItems,
|
||||
value = AppPreference.HomePageItems.getter.invoke(preferences),
|
||||
onValueChange = {
|
||||
val newPrefs = AppPreference.HomePageItems.setter.invoke(preferences, it)
|
||||
onPreferenceChange.invoke(newPrefs)
|
||||
},
|
||||
onNavigate = {},
|
||||
modifier = Modifier.focusRequester(firstFocus),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ComposablePreference(
|
||||
preference = AppPreference.RewatchNextUp,
|
||||
value = AppPreference.RewatchNextUp.getter.invoke(preferences),
|
||||
onValueChange = {
|
||||
val newPrefs = AppPreference.RewatchNextUp.setter.invoke(preferences, it)
|
||||
onPreferenceChange.invoke(newPrefs)
|
||||
},
|
||||
onNavigate = {},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
ComposablePreference(
|
||||
preference = AppPreference.MaxDaysNextUp,
|
||||
value = AppPreference.MaxDaysNextUp.getter.invoke(preferences),
|
||||
onValueChange = {
|
||||
val newPrefs = AppPreference.MaxDaysNextUp.setter.invoke(preferences, it)
|
||||
onPreferenceChange.invoke(newPrefs)
|
||||
},
|
||||
onNavigate = {},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.increase_all_cards_size),
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowUp,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = { onClickResize.invoke(1) },
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.decrease_all_cards_size),
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = { onClickResize.invoke(-1) },
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.save_to_server),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_cloud_arrow_up),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickSave,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.load_from_server),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_cloud_arrow_down),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickLoad,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.load_from_web_client),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_download),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickLoadWeb,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.reset),
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_arrows_rotate),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = onClickReset,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.ListItemBorder
|
||||
import androidx.tv.material3.ListItemColors
|
||||
import androidx.tv.material3.ListItemDefaults
|
||||
import androidx.tv.material3.ListItemGlow
|
||||
import androidx.tv.material3.ListItemScale
|
||||
import androidx.tv.material3.ListItemShape
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceTitle
|
||||
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun HomeSettingsListItem(
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
headlineText: String,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
overlineContent: (@Composable () -> Unit)? = null,
|
||||
supportingContent: (@Composable () -> Unit)? = null,
|
||||
leadingContent: (@Composable BoxScope.() -> Unit)? = null,
|
||||
trailingContent: (@Composable () -> Unit)? = null,
|
||||
tonalElevation: Dp = 3.dp,
|
||||
shape: ListItemShape = ListItemDefaults.shape(),
|
||||
colors: ListItemColors = ListItemDefaults.colors(),
|
||||
scale: ListItemScale = ListItemDefaults.scale(),
|
||||
border: ListItemBorder = ListItemDefaults.border(),
|
||||
glow: ListItemGlow = ListItemDefaults.glow(),
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
) = ListItem(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
headlineContent = {
|
||||
PreferenceTitle(headlineText)
|
||||
},
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
onLongClick = onLongClick,
|
||||
overlineContent = overlineContent,
|
||||
supportingContent = supportingContent,
|
||||
leadingContent = leadingContent,
|
||||
trailingContent = trailingContent,
|
||||
tonalElevation = tonalElevation,
|
||||
shape = shape,
|
||||
colors = colors,
|
||||
scale = scale,
|
||||
border = border,
|
||||
glow = glow,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.main.HomePageContent
|
||||
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType
|
||||
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.RowSettings
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
val settingsWidth = 360.dp
|
||||
|
||||
@Composable
|
||||
fun HomeSettingsPage(
|
||||
modifier: Modifier,
|
||||
viewModel: HomeSettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
val backStack = rememberNavBackStack(HomeSettingsDestination.RowList)
|
||||
var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) }
|
||||
|
||||
val state by viewModel.state.collectAsState()
|
||||
// TODO discover rows
|
||||
val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false)
|
||||
|
||||
// Adds a row, waits until its done loading, then scrolls to the new row
|
||||
fun addRow(func: () -> Job) {
|
||||
scope.launch(ExceptionHandler(autoToast = true)) {
|
||||
backStack.add(HomeSettingsDestination.RowList)
|
||||
func.invoke().join()
|
||||
listState.animateScrollToItem(state.rows.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(settingsWidth)
|
||||
.fillMaxHeight()
|
||||
.background(color = MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
// onBack = { navigationManager.goBack() },
|
||||
entryDecorators =
|
||||
listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator(),
|
||||
),
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
|
||||
entryProvider = { key ->
|
||||
val dest = key as HomeSettingsDestination
|
||||
NavEntry(dest, contentKey = key.toString()) {
|
||||
val destModifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
when (dest) {
|
||||
HomeSettingsDestination.RowList -> {
|
||||
HomeSettingsRowList(
|
||||
state = state,
|
||||
onClickAdd = { backStack.add(HomeSettingsDestination.AddRow) },
|
||||
onClickSettings = { backStack.add(HomeSettingsDestination.GlobalSettings) },
|
||||
onClickPresets = { backStack.add(HomeSettingsDestination.Presets) },
|
||||
onClickMove = viewModel::moveRow,
|
||||
onClickDelete = viewModel::deleteRow,
|
||||
onClick = { index, row ->
|
||||
backStack.add(RowSettings(row.id))
|
||||
scope.launch(ExceptionHandler()) {
|
||||
Timber.v("Scroll to $index")
|
||||
listState.scrollToItem(index)
|
||||
}
|
||||
},
|
||||
modifier = destModifier,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeSettingsDestination.AddRow -> {
|
||||
HomeSettingsAddRow(
|
||||
libraries = state.libraries,
|
||||
showDiscover = discoverEnabled,
|
||||
onClick = { backStack.add(ChooseRowType(it)) },
|
||||
onClickMeta = {
|
||||
when (it) {
|
||||
MetaRowType.CONTINUE_WATCHING,
|
||||
MetaRowType.NEXT_UP,
|
||||
MetaRowType.COMBINED_CONTINUE_WATCHING,
|
||||
-> {
|
||||
addRow { viewModel.addRow(it) }
|
||||
}
|
||||
|
||||
MetaRowType.FAVORITES -> {
|
||||
backStack.add(HomeSettingsDestination.ChooseFavorite)
|
||||
}
|
||||
|
||||
MetaRowType.DISCOVER -> {
|
||||
backStack.add(HomeSettingsDestination.ChooseDiscover)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = destModifier,
|
||||
)
|
||||
}
|
||||
|
||||
is ChooseRowType -> {
|
||||
HomeLibraryRowTypeList(
|
||||
library = dest.library,
|
||||
onClick = { type ->
|
||||
addRow { viewModel.addRow(dest.library, type) }
|
||||
},
|
||||
modifier = destModifier,
|
||||
)
|
||||
}
|
||||
|
||||
is RowSettings -> {
|
||||
val row =
|
||||
state.rows
|
||||
.first { it.id == dest.rowId }
|
||||
val preferenceOptions =
|
||||
remember(row.config) {
|
||||
when (row.config) {
|
||||
is HomeRowConfig.ContinueWatching,
|
||||
is HomeRowConfig.ContinueWatchingCombined,
|
||||
-> Options.OPTIONS_EPISODES
|
||||
|
||||
is HomeRowConfig.Genres -> Options.GENRE_OPTIONS
|
||||
|
||||
else -> Options.OPTIONS
|
||||
}
|
||||
}
|
||||
val defaultViewOptions =
|
||||
remember(row.config) {
|
||||
when (row.config) {
|
||||
is HomeRowConfig.Genres -> HomeRowViewOptions.genreDefault
|
||||
else -> HomeRowViewOptions()
|
||||
}
|
||||
}
|
||||
HomeRowSettings(
|
||||
title = row.title,
|
||||
preferenceOptions = preferenceOptions,
|
||||
viewOptions = row.config.viewOptions,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
onViewOptionsChange = {
|
||||
viewModel.updateViewOptions(dest.rowId, it)
|
||||
},
|
||||
onApplyApplyAll = {
|
||||
viewModel.updateViewOptionsForAll(row.config.viewOptions)
|
||||
},
|
||||
modifier = destModifier,
|
||||
)
|
||||
}
|
||||
|
||||
HomeSettingsDestination.ChooseDiscover -> {
|
||||
TODO()
|
||||
}
|
||||
|
||||
HomeSettingsDestination.ChooseFavorite -> {
|
||||
HomeSettingsFavoriteList(
|
||||
onClick = { type ->
|
||||
addRow { viewModel.addFavoriteRow(type) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
HomeSettingsDestination.GlobalSettings -> {
|
||||
val preferences by
|
||||
viewModel.preferencesDataStore.data.collectAsState(
|
||||
AppPreferences.getDefaultInstance(),
|
||||
)
|
||||
|
||||
HomeSettingsGlobal(
|
||||
preferences = preferences,
|
||||
onPreferenceChange = { newPrefs ->
|
||||
scope.launchIO {
|
||||
viewModel.preferencesDataStore.updateData { newPrefs }
|
||||
}
|
||||
},
|
||||
onClickResize = { viewModel.resizeCards(it) },
|
||||
onClickSave = {
|
||||
showConfirmDialog =
|
||||
ShowConfirm(R.string.overwrite_server_settings) {
|
||||
viewModel.saveToRemote()
|
||||
}
|
||||
},
|
||||
onClickLoad = {
|
||||
showConfirmDialog =
|
||||
ShowConfirm(R.string.overwrite_local_settings) {
|
||||
viewModel.loadFromRemote()
|
||||
}
|
||||
},
|
||||
onClickLoadWeb = {
|
||||
showConfirmDialog =
|
||||
ShowConfirm(R.string.overwrite_local_settings) {
|
||||
viewModel.loadFromRemoteWeb()
|
||||
}
|
||||
},
|
||||
onClickReset = {
|
||||
showConfirmDialog =
|
||||
ShowConfirm(R.string.overwrite_local_settings) {
|
||||
viewModel.resetToDefault()
|
||||
}
|
||||
},
|
||||
modifier = destModifier,
|
||||
)
|
||||
}
|
||||
|
||||
HomeSettingsDestination.Presets -> {
|
||||
HomeRowPresetsContent(
|
||||
onApply = viewModel::applyPreset,
|
||||
modifier = destModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
HomePageContent(
|
||||
loadingState = state.loading,
|
||||
homeRows = state.rowData,
|
||||
onClickItem = { _, _ -> },
|
||||
onLongClickItem = { _, _ -> },
|
||||
onClickPlay = { _, _ -> },
|
||||
showClock = false,
|
||||
onUpdateBackdrop = viewModel::updateBackdrop,
|
||||
listState = listState,
|
||||
takeFocus = false,
|
||||
showEmptyRows = true,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(1f),
|
||||
)
|
||||
}
|
||||
showConfirmDialog?.let { (body, onConfirm) ->
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.confirm),
|
||||
body = stringResource(body),
|
||||
onCancel = { showConfirmDialog = null },
|
||||
onConfirm = {
|
||||
onConfirm.invoke()
|
||||
showConfirmDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class ShowConfirm(
|
||||
@param:StringRes val body: Int,
|
||||
val onConfirm: () -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.components.Button
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class MoveDirection {
|
||||
UP,
|
||||
DOWN,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeSettingsRowList(
|
||||
state: HomePageSettingsState,
|
||||
onClick: (Int, HomeRowConfigDisplay) -> Unit,
|
||||
onClickAdd: () -> Unit,
|
||||
onClickSettings: () -> Unit,
|
||||
onClickPresets: () -> Unit,
|
||||
onClickMove: (MoveDirection, Int) -> Unit,
|
||||
onClickDelete: (Int) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val itemsBeforeRows = 4
|
||||
val focusRequesters =
|
||||
remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } }
|
||||
|
||||
var position by rememberInt(0)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequesters.getOrNull(position)?.tryRequestFocus()
|
||||
}
|
||||
Column(modifier = modifier) {
|
||||
TitleText(stringResource(R.string.customize_home))
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxHeight()
|
||||
.focusRestorer(focusRequesters[0]),
|
||||
) {
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.add_row),
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
position = 0
|
||||
onClickAdd.invoke()
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[0]),
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.settings),
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
position = 1
|
||||
onClickSettings.invoke()
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[1]),
|
||||
)
|
||||
}
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(R.string.display_presets),
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.display_presets_description),
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_sliders),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
position = 2
|
||||
onClickPresets.invoke()
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[1]),
|
||||
)
|
||||
}
|
||||
item {
|
||||
TitleText(stringResource(R.string.home_rows))
|
||||
HorizontalDivider()
|
||||
}
|
||||
itemsIndexed(state.rows, key = { _, row -> row.id }) { index, row ->
|
||||
HomeRowConfigContent(
|
||||
config = row,
|
||||
moveUpAllowed = index > 0,
|
||||
moveDownAllowed = index != state.rows.lastIndex,
|
||||
deleteAllowed = state.rows.size > 1,
|
||||
onClickMove = {
|
||||
onClickMove.invoke(it, index)
|
||||
scope.launch {
|
||||
val scrollIndex =
|
||||
itemsBeforeRows + if (it == MoveDirection.UP) index - 1 else index + 1
|
||||
if (scrollIndex < listState.firstVisibleItemIndex ||
|
||||
scrollIndex > listState.layoutInfo.visibleItemsInfo.lastIndex
|
||||
) {
|
||||
listState.animateScrollToItem(scrollIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClickDelete = {
|
||||
if (index != state.rows.lastIndex) {
|
||||
focusManager.moveFocus(FocusDirection.Down)
|
||||
} else {
|
||||
focusManager.moveFocus(FocusDirection.Up)
|
||||
}
|
||||
onClickDelete.invoke(index)
|
||||
},
|
||||
onClick = {
|
||||
position = itemsBeforeRows + index
|
||||
onClick.invoke(index, row)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItem()
|
||||
.focusRequester(focusRequesters[itemsBeforeRows + index]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeRowConfigContent(
|
||||
config: HomeRowConfigDisplay,
|
||||
moveUpAllowed: Boolean,
|
||||
moveDownAllowed: Boolean,
|
||||
deleteAllowed: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onClickMove: (MoveDirection) -> Unit,
|
||||
onClickDelete: () -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 40.dp, max = 88.dp),
|
||||
) {
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = config.title,
|
||||
onClick = onClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
) {
|
||||
Button(
|
||||
onClick = { onClickMove.invoke(MoveDirection.UP) },
|
||||
enabled = moveUpAllowed,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_caret_up),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { onClickMove.invoke(MoveDirection.DOWN) },
|
||||
enabled = moveDownAllowed,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fa_caret_down),
|
||||
fontFamily = FontAwesome,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onClickDelete,
|
||||
enabled = deleteAllowed,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "delete",
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun TitleText(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,670 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.HomePageSettings
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatching
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatchingCombined
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig.Genres
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig.NextUp
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyAdded
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyReleased
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig.Suggestions
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
|
||||
import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
|
||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||
import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
@HiltViewModel
|
||||
class HomeSettingsViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
private val homeSettingsService: HomeSettingsService,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val navDrawerItemRepository: NavDrawerItemRepository,
|
||||
private val backdropService: BackdropService,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
val preferencesDataStore: DataStore<AppPreferences>,
|
||||
@param:IoCoroutineScope private val ioScope: CoroutineScope,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
|
||||
val state: StateFlow<HomePageSettingsState> = _state
|
||||
|
||||
private var idCounter by Delegates.notNull<Int>()
|
||||
|
||||
val discoverEnabled = seerrServerRepository.active
|
||||
|
||||
init {
|
||||
addCloseable { saveToLocal() }
|
||||
viewModelScope.launchIO {
|
||||
val navDrawerItems =
|
||||
navDrawerItemRepository
|
||||
.getNavDrawerItems()
|
||||
val libraries =
|
||||
navDrawerItems
|
||||
.filter { it is ServerNavDrawerItem }
|
||||
.map {
|
||||
it as ServerNavDrawerItem
|
||||
Library(it.itemId, it.name, it.type)
|
||||
}
|
||||
val currentSettings =
|
||||
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
|
||||
Timber.v("currentSettings=%s", currentSettings)
|
||||
idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||
_state.update {
|
||||
it.copy(
|
||||
libraries = libraries,
|
||||
rows = currentSettings.rows,
|
||||
)
|
||||
}
|
||||
fetchRowData()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackdrop(item: BaseItem) {
|
||||
viewModelScope.launchIO {
|
||||
backdropService.submit(item)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchRowData() {
|
||||
val limit = 6
|
||||
val semaphore = Semaphore(4)
|
||||
val rows =
|
||||
serverRepository.currentUserDto.value?.let { userDto ->
|
||||
val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences
|
||||
state.value
|
||||
.let { state ->
|
||||
state.rows
|
||||
.map { it.config }
|
||||
.map { row ->
|
||||
viewModelScope.async(Dispatchers.IO) {
|
||||
semaphore.withPermit {
|
||||
homeSettingsService.fetchDataForRow(
|
||||
row = row,
|
||||
scope = viewModelScope,
|
||||
prefs = prefs,
|
||||
userDto = userDto,
|
||||
libraries = state.libraries,
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
rows?.let { rows ->
|
||||
rows
|
||||
.firstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
|
||||
?.let {
|
||||
it as HomeRowLoadingState.Success
|
||||
it.items.firstOrNull()?.let {
|
||||
Timber.v("Updating backdrop")
|
||||
updateBackdrop(it)
|
||||
}
|
||||
}
|
||||
updateState {
|
||||
it.copy(loading = LoadingState.Success, rowData = rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> List<T>.move(
|
||||
direction: MoveDirection,
|
||||
index: Int,
|
||||
): List<T> =
|
||||
toMutableList().apply {
|
||||
if (direction == MoveDirection.DOWN) {
|
||||
val down = this[index]
|
||||
val up = this[index + 1]
|
||||
set(index, up)
|
||||
set(index + 1, down)
|
||||
} else {
|
||||
val up = this[index]
|
||||
val down = this[index - 1]
|
||||
set(index - 1, up)
|
||||
set(index, down)
|
||||
}
|
||||
}
|
||||
|
||||
fun moveRow(
|
||||
direction: MoveDirection,
|
||||
index: Int,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
updateState {
|
||||
val rows = it.rows.move(direction, index)
|
||||
val rowData = it.rowData.move(direction, index)
|
||||
it.copy(
|
||||
rows = rows,
|
||||
rowData = rowData,
|
||||
)
|
||||
}
|
||||
}
|
||||
// viewModelScope.launchIO { fetchRowData() }
|
||||
}
|
||||
|
||||
fun deleteRow(index: Int) {
|
||||
viewModelScope.launchIO {
|
||||
updateState {
|
||||
val rows = it.rows.toMutableList().apply { removeAt(index) }
|
||||
val rowData = it.rowData.toMutableList().apply { removeAt(index) }
|
||||
it.copy(
|
||||
rows = rows,
|
||||
rowData = rowData,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addRow(type: MetaRowType): Job =
|
||||
viewModelScope.launchIO {
|
||||
val id = idCounter++
|
||||
val newRow =
|
||||
when (type) {
|
||||
MetaRowType.CONTINUE_WATCHING -> {
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = context.getString(R.string.continue_watching),
|
||||
config = ContinueWatching(),
|
||||
)
|
||||
}
|
||||
|
||||
MetaRowType.NEXT_UP -> {
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = context.getString(R.string.continue_watching),
|
||||
config = NextUp(),
|
||||
)
|
||||
}
|
||||
|
||||
MetaRowType.COMBINED_CONTINUE_WATCHING -> {
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = context.getString(R.string.combine_continue_next),
|
||||
config = ContinueWatchingCombined(),
|
||||
)
|
||||
}
|
||||
|
||||
MetaRowType.FAVORITES -> {
|
||||
throw IllegalArgumentException("Should use addRow(BaseItemKind) instead")
|
||||
}
|
||||
|
||||
MetaRowType.DISCOVER -> {
|
||||
TODO()
|
||||
}
|
||||
}
|
||||
updateState {
|
||||
it.copy(
|
||||
loading = LoadingState.Loading,
|
||||
rows = it.rows.toMutableList().apply { add(newRow) },
|
||||
)
|
||||
}
|
||||
fetchRowData()
|
||||
}
|
||||
|
||||
fun addRow(
|
||||
library: Library,
|
||||
rowType: LibraryRowType,
|
||||
): Job =
|
||||
viewModelScope.launchIO {
|
||||
val id = idCounter++
|
||||
val newRow =
|
||||
when (rowType) {
|
||||
LibraryRowType.RECENTLY_ADDED -> {
|
||||
val title =
|
||||
library.name.let { context.getString(R.string.recently_added_in, it) }
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = title,
|
||||
config = RecentlyAdded(library.itemId),
|
||||
)
|
||||
}
|
||||
|
||||
LibraryRowType.RECENTLY_RELEASED -> {
|
||||
val title =
|
||||
library.name.let {
|
||||
context.getString(
|
||||
R.string.recently_released_in,
|
||||
it,
|
||||
)
|
||||
}
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = title,
|
||||
config = RecentlyReleased(library.itemId),
|
||||
)
|
||||
}
|
||||
|
||||
LibraryRowType.GENRES -> {
|
||||
val title = library.name.let { context.getString(R.string.genres_in, it) }
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = title,
|
||||
config = Genres(library.itemId),
|
||||
)
|
||||
}
|
||||
|
||||
LibraryRowType.SUGGESTIONS -> {
|
||||
val title =
|
||||
library.name.let { context.getString(R.string.suggestions_for, it) }
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = title,
|
||||
config = Suggestions(library.itemId),
|
||||
)
|
||||
}
|
||||
}
|
||||
updateState {
|
||||
it.copy(
|
||||
loading = LoadingState.Loading,
|
||||
rows = it.rows.toMutableList().apply { add(newRow) },
|
||||
)
|
||||
}
|
||||
fetchRowData()
|
||||
}
|
||||
|
||||
fun addFavoriteRow(type: BaseItemKind): Job =
|
||||
viewModelScope.launchIO {
|
||||
Timber.v("Adding favorite row for $type")
|
||||
val id = idCounter++
|
||||
val newRow =
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = context.getString(favoriteOptions[type]!!),
|
||||
config = HomeRowConfig.Favorite(type),
|
||||
)
|
||||
updateState {
|
||||
it.copy(
|
||||
loading = LoadingState.Loading,
|
||||
rows = it.rows.toMutableList().apply { add(newRow) },
|
||||
)
|
||||
}
|
||||
fetchRowData()
|
||||
}
|
||||
|
||||
fun updateViewOptions(
|
||||
rowId: Int,
|
||||
viewOptions: HomeRowViewOptions,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
var fetchData = false
|
||||
updateState {
|
||||
val index = it.rows.indexOfFirst { it.id == rowId }
|
||||
val config = it.rows[index].config
|
||||
val newRowConfig = config.updateViewOptions(viewOptions)
|
||||
val newRow = it.rows[index].copy(config = newRowConfig)
|
||||
if (config.viewOptions.useSeries != viewOptions.useSeries) {
|
||||
fetchData = true
|
||||
}
|
||||
it.copy(
|
||||
rows =
|
||||
it.rows.toMutableList().apply {
|
||||
set(index, newRow)
|
||||
},
|
||||
rowData =
|
||||
it.rowData.toMutableList().apply {
|
||||
val row = it.rowData[index]
|
||||
val newRow =
|
||||
if (row is HomeRowLoadingState.Success) {
|
||||
row.copy(viewOptions = viewOptions)
|
||||
} else {
|
||||
row
|
||||
}
|
||||
set(index, newRow)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (fetchData) {
|
||||
fetchRowData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateViewOptionsForAll(viewOptions: HomeRowViewOptions) {
|
||||
viewModelScope.launchIO {
|
||||
updateState {
|
||||
it.copy(
|
||||
rowData =
|
||||
it.rowData.toMutableList().map { row ->
|
||||
if (row is HomeRowLoadingState.Success) {
|
||||
row.copy(viewOptions = viewOptions)
|
||||
} else {
|
||||
row
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveToRemote() {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
Timber.d("Saving home settings to remote")
|
||||
val rows = state.value.rows.map { it.config }
|
||||
val settings =
|
||||
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
|
||||
try {
|
||||
Timber.d("saveToRemote")
|
||||
homeSettingsService.saveToServer(user.id, settings)
|
||||
showSaveToast()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
showToast(context, "Error saving: ${ex.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFromRemote() {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
Timber.d("Loading home settings from remote")
|
||||
try {
|
||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||
val result = homeSettingsService.loadFromServer(user.id)
|
||||
if (result != null) {
|
||||
Timber.v("Got remote settings")
|
||||
val newRows =
|
||||
result.rows.mapIndexed { index, config ->
|
||||
homeSettingsService.resolve(index, config)
|
||||
}
|
||||
_state.update {
|
||||
it.copy(rows = newRows)
|
||||
}
|
||||
} else {
|
||||
Timber.v("No remote settings")
|
||||
showToast(context, "No server-side settings found")
|
||||
}
|
||||
fetchRowData()
|
||||
} catch (ex: UnsupportedHomeSettingsVersionException) {
|
||||
// TODO
|
||||
Timber.w(ex)
|
||||
showToast(context, "Error: ${ex.localizedMessage}")
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
showToast(context, "Error: ${ex.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFromRemoteWeb() {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
Timber.d("Loading home settings from web")
|
||||
try {
|
||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||
val result = homeSettingsService.parseFromWebConfig(user.id)
|
||||
if (result != null) {
|
||||
Timber.v("Got web settings")
|
||||
_state.update {
|
||||
it.copy(rows = result.rows)
|
||||
}
|
||||
} else {
|
||||
Timber.v("No web settings")
|
||||
showToast(context, "No server-side web settings found")
|
||||
}
|
||||
fetchRowData()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
showToast(context, "Error: ${ex.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveToLocal() {
|
||||
// This uses injected ioScope so that it will still run when the page is closing
|
||||
ioScope.launchIO {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
val rows = state.value.rows.map { it.config }
|
||||
val settings =
|
||||
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
|
||||
try {
|
||||
Timber.d("saveToLocal")
|
||||
val local = homeSettingsService.loadFromLocal(user.id)
|
||||
// Only save if there are changes
|
||||
if (local != settings) {
|
||||
homeSettingsService.saveToLocal(user.id, settings)
|
||||
homeSettingsService.updateCurrent(settings)
|
||||
showSaveToast()
|
||||
} else {
|
||||
Timber.d("No changes")
|
||||
}
|
||||
} catch (ex: UnsupportedHomeSettingsVersionException) {
|
||||
Timber.w(ex, "Overwriting local settings")
|
||||
homeSettingsService.saveToLocal(user.id, settings)
|
||||
showSaveToast()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
showToast(context, "Error saving: ${ex.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) {
|
||||
_state.update {
|
||||
update.invoke(it)
|
||||
}
|
||||
homeSettingsService.currentSettings.update { HomePageResolvedSettings(state.value.rows) }
|
||||
}
|
||||
|
||||
fun resizeCards(relative: Int) {
|
||||
viewModelScope.launchIO {
|
||||
updateState {
|
||||
val newRows =
|
||||
it.rows.toMutableList().map { row ->
|
||||
val vo = row.config.viewOptions
|
||||
val newVo = vo.copy(heightDp = vo.heightDp + (4 * relative))
|
||||
row.copy(config = row.config.updateViewOptions(newVo))
|
||||
}
|
||||
it.copy(
|
||||
rows = newRows,
|
||||
rowData =
|
||||
it.rowData.toMutableList().mapIndexed { index, row ->
|
||||
if (row is HomeRowLoadingState.Success) {
|
||||
row.copy(viewOptions = newRows[index].config.viewOptions)
|
||||
} else {
|
||||
row
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetToDefault() {
|
||||
viewModelScope.launchIO {
|
||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||
val result = homeSettingsService.createDefault()
|
||||
_state.update {
|
||||
it.copy(rows = result.rows)
|
||||
}
|
||||
fetchRowData()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun showSaveToast() =
|
||||
showToast(
|
||||
context,
|
||||
context.getString(R.string.settings_saved),
|
||||
Toast.LENGTH_SHORT,
|
||||
)
|
||||
|
||||
fun applyPreset(preset: HomeRowPresets) {
|
||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||
viewModelScope.launchIO {
|
||||
val state = state.value
|
||||
|
||||
val typeCache = mutableMapOf<UUID, CollectionType?>()
|
||||
|
||||
suspend fun getCollectionType(itemId: UUID): CollectionType =
|
||||
typeCache.getOrPut(itemId) {
|
||||
state.libraries
|
||||
.firstOrNull { it.itemId == itemId }
|
||||
?.collectionType
|
||||
?: api.userLibraryApi
|
||||
.getItem(itemId)
|
||||
.content.collectionType ?: CollectionType.UNKNOWN
|
||||
} ?: CollectionType.UNKNOWN
|
||||
|
||||
val newRows =
|
||||
state.rows.map {
|
||||
val newConfig =
|
||||
when (it.config) {
|
||||
is ContinueWatching,
|
||||
is NextUp,
|
||||
is ContinueWatchingCombined,
|
||||
-> {
|
||||
it.config.updateViewOptions(preset.continueWatching)
|
||||
}
|
||||
|
||||
is HomeRowConfig.ByParent -> {
|
||||
val collectionType = getCollectionType(it.config.parentId)
|
||||
val viewOptions = preset.getByCollectionType(collectionType)
|
||||
it.config.updateViewOptions(viewOptions)
|
||||
}
|
||||
|
||||
is HomeRowConfig.Favorite -> {
|
||||
val viewOptions =
|
||||
when (it.config.kind) {
|
||||
BaseItemKind.MOVIE -> preset.movieLibrary
|
||||
BaseItemKind.SERIES -> preset.tvLibrary
|
||||
BaseItemKind.EPISODE -> preset.continueWatching
|
||||
BaseItemKind.VIDEO -> preset.videoLibrary
|
||||
BaseItemKind.PLAYLIST -> preset.playlist
|
||||
BaseItemKind.PERSON -> preset.movieLibrary
|
||||
else -> preset.movieLibrary
|
||||
}
|
||||
it.config.updateViewOptions(viewOptions)
|
||||
}
|
||||
|
||||
is Genres -> {
|
||||
it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize))
|
||||
}
|
||||
|
||||
is HomeRowConfig.GetItems -> {
|
||||
it.config
|
||||
}
|
||||
|
||||
is RecentlyAdded -> {
|
||||
val collectionType = getCollectionType(it.config.parentId)
|
||||
val viewOptions = preset.getByCollectionType(collectionType)
|
||||
it.config.updateViewOptions(viewOptions)
|
||||
}
|
||||
|
||||
is RecentlyReleased -> {
|
||||
val collectionType = getCollectionType(it.config.parentId)
|
||||
val viewOptions = preset.getByCollectionType(collectionType)
|
||||
it.config.updateViewOptions(viewOptions)
|
||||
}
|
||||
|
||||
is HomeRowConfig.Recordings -> {
|
||||
it.config.updateViewOptions(preset.tvLibrary)
|
||||
}
|
||||
|
||||
is Suggestions -> {
|
||||
val collectionType = getCollectionType(it.config.parentId)
|
||||
val viewOptions = preset.getByCollectionType(collectionType)
|
||||
it.config.updateViewOptions(viewOptions)
|
||||
}
|
||||
|
||||
is HomeRowConfig.TvPrograms -> {
|
||||
it.config.updateViewOptions(preset.tvLibrary)
|
||||
}
|
||||
}
|
||||
it.copy(config = newConfig)
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = LoadingState.Success,
|
||||
rows = newRows,
|
||||
rowData =
|
||||
it.rowData.toMutableList().mapIndexed { index, row ->
|
||||
if (row is HomeRowLoadingState.Success) {
|
||||
row.copy(viewOptions = newRows[index].config.viewOptions)
|
||||
} else {
|
||||
row
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class HomePageSettingsState(
|
||||
val loading: LoadingState,
|
||||
val rows: List<HomeRowConfigDisplay>,
|
||||
val rowData: List<HomeRowLoadingState>,
|
||||
val libraries: List<Library>,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY =
|
||||
HomePageSettingsState(
|
||||
LoadingState.Pending,
|
||||
listOf(),
|
||||
listOf(),
|
||||
listOf(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class Library(
|
||||
@Serializable(UUIDSerializer::class) val itemId: UUID,
|
||||
val name: String,
|
||||
val collectionType: CollectionType,
|
||||
)
|
||||
|
|
@ -33,6 +33,9 @@ sealed class Destination(
|
|||
val id: Long = 0L,
|
||||
) : Destination()
|
||||
|
||||
@Serializable
|
||||
data object HomeSettings : Destination(true)
|
||||
|
||||
@Serializable
|
||||
data class Settings(
|
||||
val screen: PreferenceScreenOption,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
|
|||
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
|
||||
import com.github.damontecres.wholphin.ui.main.HomePage
|
||||
import com.github.damontecres.wholphin.ui.main.SearchPage
|
||||
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
|
||||
import com.github.damontecres.wholphin.ui.playback.PlaybackPage
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
|
||||
|
|
@ -63,6 +64,10 @@ fun DestinationContent(
|
|||
)
|
||||
}
|
||||
|
||||
is Destination.HomeSettings -> {
|
||||
HomeSettingsPage(modifier)
|
||||
}
|
||||
|
||||
is Destination.PlaybackList,
|
||||
is Destination.Playback,
|
||||
-> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue