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

@ -0,0 +1,150 @@
package com.github.damontecres.wholphin.test
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.PrefContentScale
import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import io.mockk.mockk
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.junit.Assert
import org.junit.Test
import kotlin.reflect.KClass
class TestHomeRowSamples {
companion object {
val SAMPLES =
listOf(
HomeRowConfig.RecentlyAdded(
parentId = UUID.randomUUID(),
viewOptions =
HomeRowViewOptions(
heightDp = 100,
spacing = 8,
contentScale = PrefContentScale.CROP,
aspectRatio = AspectRatio.FOUR_THREE,
imageType = ViewOptionImageType.THUMB,
showTitles = false,
useSeries = false,
),
),
HomeRowConfig.RecentlyReleased(
parentId = UUID.randomUUID(),
viewOptions = HomeRowViewOptions(),
),
HomeRowConfig.Genres(
parentId = UUID.randomUUID(),
viewOptions = HomeRowViewOptions(),
),
HomeRowConfig.ContinueWatching(
viewOptions = HomeRowViewOptions(),
),
HomeRowConfig.NextUp(
viewOptions = HomeRowViewOptions(),
),
HomeRowConfig.ContinueWatchingCombined(
viewOptions = HomeRowViewOptions(),
),
HomeRowConfig.ByParent(
parentId = UUID.randomUUID(),
recursive = true,
sort = SortAndDirection(ItemSortBy.CRITIC_RATING, SortOrder.ASCENDING),
viewOptions = HomeRowViewOptions(),
),
HomeRowConfig.GetItems(
name = "Episodes by date created",
getItems =
GetItemsRequest(
parentId = UUID.randomUUID(),
recursive = true,
isFavorite = true,
includeItemTypes = listOf(BaseItemKind.EPISODE),
sortBy = listOf(ItemSortBy.DATE_CREATED),
sortOrder = listOf(SortOrder.DESCENDING),
),
viewOptions = HomeRowViewOptions(),
),
HomeRowConfig.Favorite(kind = BaseItemKind.SERIES),
HomeRowConfig.Recordings(),
HomeRowConfig.TvPrograms(),
HomeRowConfig.Suggestions(parentId = UUID.randomUUID()),
)
}
@Test
fun `Check all types have a sample`() {
// This ensures there is a sample for each possible HomeRowConfig type
val foundTypes = mutableSetOf<KClass<out HomeRowConfig>>()
SAMPLES.forEach {
when (it) {
is HomeRowConfig.ContinueWatching -> foundTypes.add(it::class)
is HomeRowConfig.ContinueWatchingCombined -> foundTypes.add(it::class)
is HomeRowConfig.Genres -> foundTypes.add(it::class)
is HomeRowConfig.NextUp -> foundTypes.add(it::class)
is HomeRowConfig.RecentlyAdded -> foundTypes.add(it::class)
is HomeRowConfig.RecentlyReleased -> foundTypes.add(it::class)
is HomeRowConfig.ByParent -> foundTypes.add(it::class)
is HomeRowConfig.GetItems -> foundTypes.add(it::class)
is HomeRowConfig.Favorite -> foundTypes.add(it::class)
is HomeRowConfig.Recordings -> foundTypes.add(it::class)
is HomeRowConfig.TvPrograms -> foundTypes.add(it::class)
is HomeRowConfig.Suggestions -> foundTypes.add(it::class)
}
}
Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size)
}
@Test
fun `Print sample JSON`() {
// This just prints out the JSON of the samples so developers can review
val json =
Json {
ignoreUnknownKeys = true
isLenient = true
prettyPrint = true
}
val string = json.encodeToString(SAMPLES)
println(string)
json.decodeFromString<List<HomeRowConfig>>(string)
}
@Test
fun `Parse list of rows with an unknown type`() {
val service =
HomeSettingsService(
context = mockk(),
api = mockk(),
userPreferencesService = mockk(),
navDrawerItemRepository = mockk(),
latestNextUpService = mockk(),
imageUrlService = mockk(),
suggestionService = mockk(),
)
val str = """{
"type": "HomePageSettings",
"version": 1,
"rows": [
{
"type": "RecentlyAdded",
"parentId": "1dd1c2fd-2e1b-48e4-ba94-17a2350fe9cf"
},
{
"type": "Does not exist",
"viewOptions": {}
}
]
}"""
val jsonElement = service.jsonParser.parseToJsonElement(str)
val settings = service.decode(jsonElement)
Assert.assertEquals(1, settings.rows.size)
}
}