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

@ -32,6 +32,7 @@ import kotlin.time.Duration
data class BaseItem(
val data: BaseItemDto,
val useSeriesForPrimary: Boolean,
val imageUrlOverride: String? = null,
) : CardGridItem {
val id get() = data.id

View file

@ -0,0 +1,221 @@
@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.data.model
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.data.SortAndDirection
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Serializable
sealed interface HomeRowConfig {
val viewOptions: HomeRowViewOptions
fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig
/**
* Continue watching media that the user has started but not finished
*/
@Serializable
@SerialName("ContinueWatching")
data class ContinueWatching(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatching = this.copy(viewOptions = viewOptions)
}
/**
* Next up row for next episodes in a series the user has started
*/
@Serializable
@SerialName("NextUp")
data class NextUp(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): NextUp = this.copy(viewOptions = viewOptions)
}
/**
* Combined [ContinueWatching] and [NextUp]
*/
@Serializable
@SerialName("ContinueWatchingCombined")
data class ContinueWatchingCombined(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatchingCombined = this.copy(viewOptions = viewOptions)
}
/**
* Media recently added to a library
*/
@Serializable
@SerialName("RecentlyAdded")
data class RecentlyAdded(
val parentId: UUID,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyAdded = this.copy(viewOptions = viewOptions)
}
/**
* Media recently released (premiere date) in a library
*/
@Serializable
@SerialName("RecentlyReleased")
data class RecentlyReleased(
val parentId: UUID,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyReleased = this.copy(viewOptions = viewOptions)
}
/**
* Row of a genres in a library
*/
@Serializable
@SerialName("Genres")
data class Genres(
val parentId: UUID,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault,
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions)
}
/**
* Favorites for a specific type
*/
@Serializable
@SerialName("Favorite")
data class Favorite(
val kind: BaseItemKind,
override val viewOptions: HomeRowViewOptions =
if (kind == BaseItemKind.EPISODE) {
HomeRowViewOptions(
heightDp = Cards.HEIGHT_EPISODE,
aspectRatio = AspectRatio.WIDE,
)
} else {
HomeRowViewOptions()
},
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions)
}
/**
*
*/
@Serializable
@SerialName("Recordings")
data class Recordings(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Recordings = this.copy(viewOptions = viewOptions)
}
/**
*
*/
@Serializable
@SerialName("TvPrograms")
data class TvPrograms(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvPrograms = this.copy(viewOptions = viewOptions)
}
/**
* Fetch suggestions from [com.github.damontecres.wholphin.services.SuggestionService] for the given parent ID
*/
@Serializable
@SerialName("Suggestions")
data class Suggestions(
val parentId: UUID,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Suggestions = this.copy(viewOptions = viewOptions)
}
/**
* Fetch by parent ID such as a library, collection, or playlist with optional simple sorting
*/
@Serializable
@SerialName("ByParent")
data class ByParent(
val parentId: UUID,
val recursive: Boolean,
val sort: SortAndDirection? = null,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): ByParent = this.copy(viewOptions = viewOptions)
}
/**
* An arbitrary [GetItemsRequest] allowing to query for anything
*/
@Serializable
@SerialName("GetItems")
data class GetItems(
val name: String,
val getItems: GetItemsRequest,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions)
}
}
/**
* Root class for home page settings
*
* Contains the list of rows and a version
*/
@Serializable
@SerialName("HomePageSettings")
data class HomePageSettings(
val rows: List<HomeRowConfig>,
val version: Int,
) {
companion object {
val EMPTY = HomePageSettings(listOf(), SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
}
}
/**
* This is the max version supported by this version of the app
*/
const val SUPPORTED_HOME_PAGE_SETTINGS_VERSION = 1
/**
* View options for displaying a row
*
* Allows for changing things like height or aspect ratio
*/
@Serializable
data class HomeRowViewOptions(
val heightDp: Int = Cards.HEIGHT_2X3_DP,
val spacing: Int = 16,
val contentScale: PrefContentScale = PrefContentScale.FIT,
val aspectRatio: AspectRatio = AspectRatio.TALL,
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
val showTitles: Boolean = false,
val useSeries: Boolean = true,
val episodeContentScale: PrefContentScale = PrefContentScale.FIT,
val episodeAspectRatio: AspectRatio = AspectRatio.TALL,
val episodeImageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
) {
companion object {
val genreDefault =
HomeRowViewOptions(
heightDp = Cards.HEIGHT_EPISODE,
aspectRatio = AspectRatio.WIDE,
)
}
}