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:
Ray 2026-02-12 19:54:51 -05:00 committed by GitHub
parent b7679b3aa1
commit fcba1c7444
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 4297 additions and 321 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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