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( data class BaseItem(
val data: BaseItemDto, val data: BaseItemDto,
val useSeriesForPrimary: Boolean, val useSeriesForPrimary: Boolean,
val imageUrlOverride: String? = null,
) : CardGridItem { ) : CardGridItem {
val id get() = data.id 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,
)
}
}

View file

@ -656,6 +656,12 @@ sealed interface AppPreference<Pref, T> {
setter = { prefs, _ -> prefs }, setter = { prefs, _ -> prefs },
) )
val CustomizeHome =
AppDestinationPreference<AppPreferences>(
title = R.string.customize_home,
destination = Destination.HomeSettings,
)
val SendCrashReports = val SendCrashReports =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.send_crash_reports, title = R.string.send_crash_reports,
@ -1000,10 +1006,6 @@ val basicPreferences =
preferences = preferences =
listOf( listOf(
AppPreference.SignInAuto, AppPreference.SignInAuto,
AppPreference.HomePageItems,
AppPreference.CombineContinueNext,
AppPreference.RewatchNextUp,
AppPreference.MaxDaysNextUp,
AppPreference.PlayThemeMusic, AppPreference.PlayThemeMusic,
AppPreference.RememberSelectedTab, AppPreference.RememberSelectedTab,
AppPreference.SubtitleStyle, AppPreference.SubtitleStyle,
@ -1034,6 +1036,7 @@ val basicPreferences =
preferences = preferences =
listOf( listOf(
AppPreference.RequireProfilePin, AppPreference.RequireProfilePin,
AppPreference.CustomizeHome,
AppPreference.UserPinnedNavDrawerItems, AppPreference.UserPinnedNavDrawerItems,
), ),
), ),
@ -1099,6 +1102,7 @@ val advancedPreferences =
preferences = preferences =
listOf( listOf(
AppPreference.ShowClock, AppPreference.ShowClock,
AppPreference.CombineContinueNext,
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus, // AppPreference.NavDrawerSwitchOnFocus,
AppPreference.ControllerTimeout, AppPreference.ControllerTimeout,

View file

@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -47,7 +48,12 @@ class BackdropService
suspend fun submit(item: BaseItem) = suspend fun submit(item: BaseItem) =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!! val imageUrl =
if (item.type == BaseItemKind.GENRE) {
item.imageUrlOverride
} else {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
}
submit(item.id.toString(), imageUrl) submit(item.id.toString(), imageUrl)
} }

View file

@ -0,0 +1,951 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
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.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.HomePagePreferences
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToStream
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
import timber.log.Timber
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class HomeSettingsService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val userPreferencesService: UserPreferencesService,
private val navDrawerItemRepository: NavDrawerItemRepository,
private val latestNextUpService: LatestNextUpService,
private val imageUrlService: ImageUrlService,
private val suggestionService: SuggestionService,
) {
@OptIn(ExperimentalSerializationApi::class)
val jsonParser =
Json {
isLenient = true
ignoreUnknownKeys = true
allowTrailingComma = true
}
val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY)
/**
* Saves a [HomePageSettings] to the server for the user under the display preference ID
*
* @see loadFromServer
*/
suspend fun saveToServer(
userId: UUID,
settings: HomePageSettings,
displayPreferencesId: String = DISPLAY_PREF_ID,
) {
val current = getDisplayPreferences(userId, DISPLAY_PREF_ID)
val customPrefs =
current.customPrefs.toMutableMap().apply {
put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings))
}
api.displayPreferencesApi.updateDisplayPreferences(
displayPreferencesId = displayPreferencesId,
userId = userId,
client = context.getString(R.string.app_name),
data = current.copy(customPrefs = customPrefs),
)
}
/**
* Reads a [HomePageSettings] from the server for the user and display preference ID
*
* Returns null if there is none saved
*
* @see saveToServer
*/
suspend fun loadFromServer(
userId: UUID,
displayPreferencesId: String = DISPLAY_PREF_ID,
): HomePageSettings? {
val current = getDisplayPreferences(userId, displayPreferencesId)
return current.customPrefs[CUSTOM_PREF_ID]?.let {
val jsonElement = jsonParser.parseToJsonElement(it)
decode(jsonElement)
}
}
private suspend fun getDisplayPreferences(
userId: UUID,
displayPreferencesId: String,
) = api.displayPreferencesApi
.getDisplayPreferences(
userId = userId,
displayPreferencesId = displayPreferencesId,
client = context.getString(R.string.app_name),
).content
/**
* Computes the filename for locally saved [HomePageSettings]
*/
private fun filename(userId: UUID) = "${CUSTOM_PREF_ID}_${userId.toServerString()}.json"
/**
* Save the [HomePageSettings] for the user locally on the device
*
* @see loadFromLocal
*/
@OptIn(ExperimentalSerializationApi::class)
suspend fun saveToLocal(
userId: UUID,
settings: HomePageSettings,
) {
val dir = File(context.filesDir, CUSTOM_PREF_ID)
dir.mkdirs()
File(dir, filename(userId)).outputStream().use {
jsonParser.encodeToStream(settings, it)
}
}
/**
* Reads [HomePageSettings] for the user if it exists
*
* @see saveToLocal
*/
@OptIn(ExperimentalSerializationApi::class)
suspend fun loadFromLocal(userId: UUID): HomePageSettings? {
val dir = File(context.filesDir, CUSTOM_PREF_ID)
val file = File(dir, filename(userId))
return if (file.exists()) {
val fileContents = file.readText()
val jsonElement = jsonParser.parseToJsonElement(fileContents)
decode(jsonElement)
} else {
null
}
}
/**
* Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows
*
* This is public only for testing
*/
fun decode(element: JsonElement): HomePageSettings {
val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull
if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) {
throw UnsupportedHomeSettingsVersionException(version)
}
val rowsElement = element.jsonObject["rows"]?.jsonArray
val rows =
rowsElement
?.mapNotNull { row ->
try {
jsonParser.decodeFromJsonElement<HomeRowConfig>(row)
} catch (ex: Exception) {
Timber.w(ex, "Unknown row %s", row)
// TODO maybe use placeholder instead of null?
null
}
}.orEmpty()
return HomePageSettings(rows, version)
}
/**
* Loads [HomePageSettings] into [currentSettings]
*
* First checks locally, then on the server, and finally creates a default if needed
*
* Does not persist either the server nor default
*/
suspend fun loadCurrentSettings(userId: UUID) {
Timber.v("Getting setting for %s", userId)
// User local then server/remote otherwise create a default
val settings =
try {
val local = loadFromLocal(userId)
Timber.v("Found local? %s", local != null)
local
} catch (ex: Exception) {
Timber.w(ex, "Error loading local settings")
// TODO show toast?
null
} ?: try {
val remote = loadFromServer(userId)
Timber.v("Found remote? %s", remote != null)
remote
} catch (ex: Exception) {
Timber.w(ex, "Error loading remote settings")
null
}
val resolvedSettings =
if (settings != null) {
Timber.v("Found settings")
// Resolve
val resolvedRows =
settings.rows.mapIndexed { index, config ->
resolve(index, config)
}
HomePageResolvedSettings(resolvedRows)
} else {
createDefault()
}
currentSettings.update { resolvedSettings }
}
suspend fun updateCurrent(settings: HomePageSettings) {
val resolvedRows =
settings.rows.mapIndexed { index, config ->
resolve(index, config)
}
val resolvedSettings = HomePageResolvedSettings(resolvedRows)
currentSettings.update { resolvedSettings }
}
/**
* Create a default [HomePageResolvedSettings] using the available libraries
*/
suspend fun createDefault(): HomePageResolvedSettings {
Timber.v("Creating default settings")
val navDrawerItems = navDrawerItemRepository.getNavDrawerItems()
val libraries =
navDrawerItems
.filter { it is ServerNavDrawerItem }
.map {
it as ServerNavDrawerItem
Library(it.itemId, it.name, it.type)
}
val prefs =
userPreferencesService.getCurrent().appPreferences.homePagePreferences
val includedIds =
navDrawerItemRepository
.getFilteredNavDrawerItems(navDrawerItems)
.filter { it is ServerNavDrawerItem }
.mapIndexed { index, it ->
val parentId = (it as ServerNavDrawerItem).itemId
val name = libraries.firstOrNull { it.itemId == parentId }?.name
val title =
name?.let { context.getString(R.string.recently_added_in, it) }
?: context.getString(R.string.recently_added)
HomeRowConfigDisplay(
id = index,
title = title,
config = HomeRowConfig.RecentlyAdded(parentId),
)
}
val continueWatchingRows =
if (prefs.combineContinueNext) { // TODO
listOf(
HomeRowConfigDisplay(
id = includedIds.size + 1,
title = context.getString(R.string.combine_continue_next),
config = HomeRowConfig.ContinueWatchingCombined(),
),
)
} else {
listOf(
HomeRowConfigDisplay(
id = includedIds.size + 1,
title = context.getString(R.string.continue_watching),
config = HomeRowConfig.ContinueWatching(),
),
HomeRowConfigDisplay(
id = includedIds.size + 2,
title = context.getString(R.string.next_up),
config = HomeRowConfig.NextUp(),
),
)
}
val rowConfig = continueWatchingRows + includedIds
return HomePageResolvedSettings(rowConfig)
}
suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? {
val customPrefs =
api.displayPreferencesApi
.getDisplayPreferences(
displayPreferencesId = "usersettings",
userId = userId,
client = "emby",
).content.customPrefs
val userDto by api.userApi.getUserById(userId)
val config = userDto.configuration ?: DefaultUserConfiguration
val libraries =
api.userViewsApi
.getUserViews(userId = userId)
.content.items
.filter {
it.collectionType in supportedHomeCollectionTypes &&
it.id !in config.latestItemsExcludes
}
return if (customPrefs.isNotEmpty()) {
var id = 0
val rowConfigs =
(0..9)
.mapNotNull { idx ->
val sectionType =
HomeSectionType.fromString(customPrefs["homesection$idx"]?.lowercase())
Timber.v(
"sectionType=$sectionType, %s",
customPrefs["homesection$idx"]?.lowercase(),
)
val config =
when (sectionType) {
HomeSectionType.ACTIVE_RECORDINGS -> {
HomeRowConfigDisplay(
id = id++,
title = context.getString(R.string.active_recordings),
config = HomeRowConfig.Recordings(),
)
}
HomeSectionType.RESUME -> {
HomeRowConfigDisplay(
id = id++,
title = context.getString(R.string.continue_watching),
config = HomeRowConfig.ContinueWatching(),
)
}
HomeSectionType.NEXT_UP -> {
HomeRowConfigDisplay(
id = id++,
title = context.getString(R.string.next_up),
config = HomeRowConfig.NextUp(),
)
}
HomeSectionType.LIVE_TV -> {
if (userDto.policy?.enableLiveTvAccess == true) {
HomeRowConfigDisplay(
id = id++,
title = context.getString(R.string.live_tv),
config = HomeRowConfig.TvPrograms(),
)
} else {
null
}
}
HomeSectionType.LATEST_MEDIA -> {
// Handled below
null
}
// Unsupported
HomeSectionType.RESUME_AUDIO,
HomeSectionType.RESUME_BOOK,
-> {
null
}
HomeSectionType.SMALL_LIBRARY_TILES,
HomeSectionType.LIBRARY_BUTTONS,
HomeSectionType.NONE,
null,
-> {
null
}
}
if (sectionType == HomeSectionType.LATEST_MEDIA) {
libraries.map {
HomeRowConfigDisplay(
id = id++,
title =
context.getString(
R.string.recently_added_in,
it.name ?: "",
),
config = HomeRowConfig.RecentlyAdded(it.id),
)
}
} else if (config != null) {
listOf(config)
} else {
null
}
}.flatten()
HomePageResolvedSettings(rowConfigs)
} else {
null
}
}
/**
* Converts a [HomeRowConfig] into [HomeRowConfigDisplay] for UI purposes
*/
suspend fun resolve(
id: Int,
config: HomeRowConfig,
): HomeRowConfigDisplay =
when (config) {
is HomeRowConfig.ByParent -> {
val name =
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay(
id,
name,
config,
)
}
is HomeRowConfig.ContinueWatching -> {
HomeRowConfigDisplay(
id,
context.getString(R.string.continue_watching),
config,
)
}
is HomeRowConfig.ContinueWatchingCombined -> {
HomeRowConfigDisplay(
id,
context.getString(R.string.combine_continue_next),
config,
)
}
is HomeRowConfig.Genres -> {
val name =
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay(
id,
context.getString(R.string.genres_in, name),
config,
)
}
is HomeRowConfig.GetItems -> {
HomeRowConfigDisplay(id, config.name, config)
}
is HomeRowConfig.NextUp -> {
HomeRowConfigDisplay(
id,
context.getString(R.string.next_up),
config,
)
}
is HomeRowConfig.RecentlyAdded -> {
val name =
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay(
id,
context.getString(R.string.recently_added_in, name),
config,
)
}
is HomeRowConfig.RecentlyReleased -> {
val name =
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay(
id,
context.getString(R.string.recently_released_in, name),
config,
)
}
is HomeRowConfig.Favorite -> {
val name = context.getString(R.string.favorites) // TODO "Favorite <type>"
HomeRowConfigDisplay(id, name, config)
}
is HomeRowConfig.Recordings -> {
HomeRowConfigDisplay(
id = id,
title = context.getString(R.string.active_recordings),
config,
)
}
is HomeRowConfig.TvPrograms -> {
HomeRowConfigDisplay(
id = id,
title = context.getString(R.string.live_tv),
config,
)
}
is HomeRowConfig.Suggestions -> {
val name =
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay(
id = id,
title = context.getString(R.string.suggestions_for, name),
config,
)
}
}
/**
* Fetch the data from the server for a given [HomeRowConfig]
*/
suspend fun fetchDataForRow(
row: HomeRowConfig,
scope: CoroutineScope,
prefs: HomePagePreferences,
userDto: UserDto,
libraries: List<Library>,
limit: Int = prefs.maxItemsPerRow,
): HomeRowLoadingState =
when (row) {
is HomeRowConfig.ContinueWatching -> {
val resume =
latestNextUpService.getResume(
userDto.id,
limit,
true,
row.viewOptions.useSeries,
)
Success(
title = context.getString(R.string.continue_watching),
items = resume,
viewOptions = row.viewOptions,
)
}
is HomeRowConfig.NextUp -> {
val nextUp =
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
Success(
title = context.getString(R.string.next_up),
items = nextUp,
viewOptions = row.viewOptions,
)
}
is HomeRowConfig.ContinueWatchingCombined -> {
val resume =
latestNextUpService.getResume(
userDto.id,
limit,
true,
row.viewOptions.useSeries,
)
val nextUp =
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
Success(
title = context.getString(R.string.continue_watching),
items =
latestNextUpService.buildCombined(
resume,
nextUp,
),
viewOptions = row.viewOptions,
)
}
is HomeRowConfig.Genres -> {
val request =
GetGenresRequest(
parentId = row.parentId,
userId = userDto.id,
limit = limit,
)
val items =
GetGenresRequestHandler
.execute(api, request)
.content.items
val genreIds = items.map { it.id }
val genreImages =
getGenreImageMap(
api = api,
scope = scope,
imageUrlService = imageUrlService,
genres = genreIds,
parentId = row.parentId,
includeItemTypes = null,
cardWidthPx = null,
)
val genres =
items.map {
BaseItem(it, false, genreImages[it.id])
}
val name =
libraries
.firstOrNull { it.itemId == row.parentId }
?.name
val title =
name?.let { context.getString(R.string.genres_in, it) }
?: context.getString(R.string.genres)
Success(
title,
genres,
viewOptions = row.viewOptions,
)
}
is HomeRowConfig.RecentlyAdded -> {
val name =
libraries
.firstOrNull { it.itemId == row.parentId }
?.name
val title =
name?.let { context.getString(R.string.recently_added_in, it) }
?: context.getString(R.string.recently_added)
val request =
GetLatestMediaRequest(
fields = SlimItemFields,
imageTypeLimit = 1,
parentId = row.parentId,
groupItems = true,
limit = limit,
isPlayed = null, // Server will handle user's preference
)
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) }
.let {
Success(
title,
it,
row.viewOptions,
)
}
latest
}
is HomeRowConfig.RecentlyReleased -> {
val name =
libraries
.firstOrNull { it.itemId == row.parentId }
?.name
val title =
name?.let {
context.getString(R.string.recently_released_in, it)
} ?: context.getString(R.string.recently_released)
val request =
GetItemsRequest(
parentId = row.parentId,
limit = limit,
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
sortOrder = listOf(SortOrder.DESCENDING),
fields = DefaultItemFields,
recursive = true,
)
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) }
.let {
Success(
title,
it,
row.viewOptions,
)
}
}
is HomeRowConfig.ByParent -> {
val request =
GetItemsRequest(
userId = userDto.id,
parentId = row.parentId,
recursive = row.recursive,
sortBy = row.sort?.let { listOf(it.sort) },
sortOrder = row.sort?.let { listOf(it.direction) },
limit = limit,
fields = DefaultItemFields,
)
val name =
api.userLibraryApi
.getItem(itemId = row.parentId)
.content.name
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
.let {
Success(
name ?: context.getString(R.string.collection),
it,
row.viewOptions,
)
}
}
is HomeRowConfig.GetItems -> {
val request =
row.getItems.let {
if (it.limit == null) {
it.copy(
userId = userDto.id,
limit = limit,
)
} else {
it.copy(
userId = userDto.id,
)
}
}
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
.let {
Success(
row.name,
it,
row.viewOptions,
)
}
}
is HomeRowConfig.Favorite -> {
if (row.kind == BaseItemKind.PERSON) {
val request =
GetPersonsRequest(
userId = userDto.id,
limit = limit,
fields = DefaultItemFields,
isFavorite = true,
enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY),
)
GetPersonsHandler
.execute(api, request)
.content.items
.map { BaseItem(it, true) }
.let {
Success(
context.getString(R.string.favorites), // TODO
it,
row.viewOptions,
)
}
} else {
val request =
GetItemsRequest(
userId = userDto.id,
recursive = true,
limit = limit,
fields = DefaultItemFields,
includeItemTypes = listOf(row.kind),
isFavorite = true,
)
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
.let {
Success(
context.getString(R.string.favorites), // TODO
it,
row.viewOptions,
)
}
}
}
is HomeRowConfig.Recordings -> {
val request =
GetRecordingsRequest(
userId = userDto.id,
isInProgress = true,
fields = DefaultItemFields,
limit = limit,
enableImages = true,
enableUserData = true,
)
api.liveTvApi
.getRecordings(request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
.let {
Success(
context.getString(R.string.active_recordings),
it,
row.viewOptions,
)
}
}
is HomeRowConfig.TvPrograms -> {
val request =
GetRecommendedProgramsRequest(
userId = userDto.id,
fields = DefaultItemFields,
limit = limit,
enableImages = true,
enableUserData = true,
)
api.liveTvApi
.getRecommendedPrograms(request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
.let {
Success(
context.getString(R.string.live_tv),
it,
row.viewOptions,
)
}
}
is HomeRowConfig.Suggestions -> {
val library =
api.userLibraryApi
.getItem(itemId = row.parentId)
.content
val title = context.getString(R.string.suggestions_for, library.name ?: "")
val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
val suggestions =
itemKind?.let {
suggestionService
.getSuggestionsFlow(row.parentId, itemKind)
.firstOrNull()
}
if (suggestions != null && suggestions is SuggestionsResource.Success) {
Success(
title,
suggestions.items,
row.viewOptions,
)
} else if (suggestions is SuggestionsResource.Empty) {
Success(
title,
listOf(),
row.viewOptions,
)
} else {
HomeRowLoadingState.Error(
title,
message = "Unsupported type ${library.collectionType}",
)
}
}
}
companion object {
const val DISPLAY_PREF_ID = "default"
const val CUSTOM_PREF_ID = "home_settings"
}
}
/**
* A [HomeRowConfig] with a resolved ID and title so it is usable in the UI
*/
data class HomeRowConfigDisplay(
val id: Int,
val title: String,
val config: HomeRowConfig,
)
/**
* List of resolved [HomeRowConfig]s as [HomeRowConfigDisplay]s
*
* @see HomePageSettings
*/
data class HomePageResolvedSettings(
val rows: List<HomeRowConfigDisplay>,
) {
companion object {
val EMPTY = HomePageResolvedSettings(listOf())
}
}
// https://github.com/jellyfin/jellyfin/blob/v10.11.6/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/HomeSectionType.cs
enum class HomeSectionType(
val serialName: String,
) {
NONE("none"),
SMALL_LIBRARY_TILES("smalllibrarytitles"),
LIBRARY_BUTTONS("librarybuttons"),
ACTIVE_RECORDINGS("activerecordings"),
RESUME("resume"),
RESUME_AUDIO("resumeaudio"),
LATEST_MEDIA("latestmedia"),
NEXT_UP("nextup"),
LIVE_TV("livetv"),
RESUME_BOOK("resumebook"),
;
companion object {
fun fromString(homeKey: String?) = homeKey?.let { entries.firstOrNull { it.serialName == homeKey } }
}
}
class UnsupportedHomeSettingsVersionException(
val unsupportedVersion: Int?,
val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion")

View file

@ -4,8 +4,6 @@ import android.content.Context
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.main.LatestData
import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.supportItemKinds import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -21,6 +19,7 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest
@ -44,6 +43,7 @@ class LatestNextUpService
userId: UUID, userId: UUID,
limit: Int, limit: Int,
includeEpisodes: Boolean, includeEpisodes: Boolean,
useSeriesForPrimary: Boolean = true,
): List<BaseItem> { ): List<BaseItem> {
val request = val request =
GetResumeItemsRequest( GetResumeItemsRequest(
@ -66,7 +66,7 @@ class LatestNextUpService
.getResumeItems(request) .getResumeItems(request)
.content .content
.items .items
.map { BaseItem.from(it, api, true) } .map { BaseItem.from(it, api, useSeriesForPrimary) }
return items return items
} }
@ -76,6 +76,7 @@ class LatestNextUpService
enableRewatching: Boolean, enableRewatching: Boolean,
enableResumable: Boolean, enableResumable: Boolean,
maxDays: Int, maxDays: Int,
useSeriesForPrimary: Boolean = true,
): List<BaseItem> { ): List<BaseItem> {
val nextUpDateCutoff = val nextUpDateCutoff =
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) } maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
@ -96,7 +97,7 @@ class LatestNextUpService
.getNextUp(request) .getNextUp(request)
.content .content
.items .items
.map { BaseItem.from(it, api, true) } .map { BaseItem.from(it, api, useSeriesForPrimary) }
return nextUp return nextUp
} }
@ -192,3 +193,17 @@ class LatestNextUpService
return@withContext result return@withContext result
} }
} }
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 LatestData(
val title: String,
val request: GetLatestMediaRequest,
)

View file

@ -226,6 +226,7 @@ class UserSwitchListener
private val seerrServerRepository: SeerrServerRepository, private val seerrServerRepository: SeerrServerRepository,
private val seerrServerDao: SeerrServerDao, private val seerrServerDao: SeerrServerDao,
private val seerrApi: SeerrApi, private val seerrApi: SeerrApi,
private val homeSettingsService: HomeSettingsService,
) { ) {
init { init {
context as AppCompatActivity context as AppCompatActivity
@ -233,41 +234,58 @@ class UserSwitchListener
serverRepository.currentUser.asFlow().collect { user -> serverRepository.currentUser.asFlow().collect { user ->
Timber.d("New user") Timber.d("New user")
seerrServerRepository.clear() seerrServerRepository.clear()
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
if (user != null) { if (user != null) {
seerrServerDao // Check for home settings
.getUsersByJellyfinUser(user.rowId) launchIO {
.firstOrNull() homeSettingsService.loadCurrentSettings(user.id)
?.let { seerrUser -> }
val server = seerrServerDao.getServer(seerrUser.serverId)?.server // Check for seerr server
if (server != null) { launchIO {
Timber.i("Found a seerr user & server") seerrServerDao
seerrApi.update(server.url, seerrUser.credential) .getUsersByJellyfinUser(user.rowId)
val userConfig = .firstOrNull()
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { ?.let { seerrUser ->
try { val server =
login( seerrServerDao.getServer(seerrUser.serverId)?.server
seerrApi.api, if (server != null) {
seerrUser.authMethod, Timber.i("Found a seerr user & server")
seerrUser.username, seerrApi.update(server.url, seerrUser.credential)
seerrUser.password, val userConfig =
) if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
} catch (ex: Exception) { try {
Timber.w(ex, "Error logging into %s", server.url) login(
seerrServerRepository.clear() seerrApi.api,
return@let seerrUser.authMethod,
seerrUser.username,
seerrUser.password,
)
} catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.clear()
return@let
}
} else {
try {
seerrApi.api.usersApi.authMeGet()
} catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.clear()
return@let
}
} }
} else { seerrServerRepository.set(server, seerrUser, userConfig)
try { }
seerrApi.api.usersApi.authMeGet()
} catch (ex: Exception) {
Timber.w(ex, "Error logging into %s", server.url)
seerrServerRepository.clear()
return@let
}
}
seerrServerRepository.set(server, seerrUser, userConfig)
} }
} }
} }
} }
} }

View file

@ -85,11 +85,8 @@ class SuggestionsWorker
views views
.mapNotNull { view -> .mapNotNull { view ->
val itemKind = val itemKind =
when (view.collectionType) { getTypeForCollection(view.collectionType)
CollectionType.MOVIES -> BaseItemKind.MOVIE ?: return@mapNotNull null
CollectionType.TVSHOWS -> BaseItemKind.SERIES
else -> return@mapNotNull null
}
async(Dispatchers.IO) { async(Dispatchers.IO) {
runCatching { runCatching {
Timber.v("Fetching suggestions for view %s", view.id) Timber.v("Fetching suggestions for view %s", view.id)
@ -267,5 +264,12 @@ class SuggestionsWorker
const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker" const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker"
const val PARAM_USER_ID = "userId" const val PARAM_USER_ID = "userId"
const val PARAM_SERVER_ID = "serverId" const val PARAM_SERVER_ID = "serverId"
fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? =
when (collectionType) {
CollectionType.MOVIES -> BaseItemKind.MOVIE
CollectionType.TVSHOWS -> BaseItemKind.SERIES
else -> null
}
} }
} }

View file

@ -80,7 +80,6 @@ class TvProviderWorker
getPotentialItems( getPotentialItems(
userId, userId,
prefs.homePagePreferences.enableRewatchingNextUp, prefs.homePagePreferences.enableRewatchingNextUp,
prefs.homePagePreferences.combineContinueNext,
prefs.homePagePreferences.maxDaysNextUp, prefs.homePagePreferences.maxDaysNextUp,
) )
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() } val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
@ -145,7 +144,6 @@ class TvProviderWorker
private suspend fun getPotentialItems( private suspend fun getPotentialItems(
userId: UUID, userId: UUID,
enableRewatching: Boolean, enableRewatching: Boolean,
combineContinueNext: Boolean,
maxDaysNextUp: Int, maxDaysNextUp: Int,
): List<BaseItem> { ): List<BaseItem> {
val resumeItems = latestNextUpService.getResume(userId, 10, true) val resumeItems = latestNextUpService.getResume(userId, 10, true)
@ -154,11 +152,7 @@ class TvProviderWorker
latestNextUpService latestNextUpService
.getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp) .getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp)
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds } .filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
return if (combineContinueNext) { return latestNextUpService.buildCombined(resumeItems, nextUpItems)
latestNextUpService.buildCombined(resumeItems, nextUpItems)
} else {
resumeItems + nextUpItems
}
} }
private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> = private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> =

View file

@ -82,8 +82,10 @@ val PhotoItemFields =
) )
object Cards { object Cards {
val height2x3 = 172.dp const val HEIGHT_2X3_DP = 172
val heightEpisode = height2x3 * .75f 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 playedPercentHeight = 6.dp
val serverUserCircle = height2x3 * .75f val serverUserCircle = height2x3 * .75f
} }

View file

@ -1,15 +1,19 @@
package com.github.damontecres.wholphin.ui.cards package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.platform.LocalDensity
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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.Cards
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.enableMarquee
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
/** /**
@ -60,6 +66,8 @@ fun BannerCard(
cardHeight: Dp = 120.dp, cardHeight: Dp = 120.dp,
aspectRatio: Float = AspectRatios.WIDE, aspectRatio: Float = AspectRatios.WIDE,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
imageType: ImageType = ImageType.PRIMARY,
imageContentScale: ContentScale = ContentScale.FillBounds,
) { ) {
val imageUrlService = LocalImageUrlService.current val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current val density = LocalDensity.current
@ -74,14 +82,15 @@ fun BannerCard(
} }
} }
val imageUrl = val imageUrl =
remember(item, fillHeight) { remember(item, fillHeight, imageType) {
if (item != null) { if (item != null) {
imageUrlService.getItemImageUrl( item.imageUrlOverride
item, ?: imageUrlService.getItemImageUrl(
ImageType.PRIMARY, item,
fillWidth = null, imageType,
fillHeight = fillHeight, fillWidth = null,
) fillHeight = fillHeight,
)
} else { } else {
null null
} }
@ -107,7 +116,7 @@ fun BannerCard(
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.FillBounds, contentScale = imageContentScale,
onError = { imageError = true }, onError = { imageError = true },
modifier = Modifier.fillMaxSize(), 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, onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, 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( Card(
modifier = modifier = modifier,
modifier,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
interactionSource = interactionSource, interactionSource = interactionSource,
@ -63,12 +81,12 @@ fun GenreCard(
.fillMaxSize() .fillMaxSize()
.clip(RoundedCornerShape(8.dp)), .clip(RoundedCornerShape(8.dp)),
) { ) {
if (genre?.imageUrl.isNotNullOrBlank()) { if (imageUrl != null) {
AsyncImage( AsyncImage(
model = model =
ImageRequest ImageRequest
.Builder(LocalContext.current) .Builder(LocalContext.current)
.data(genre.imageUrl) .data(imageUrl)
.crossfade(true) .crossfade(true)
.build(), .build(),
contentScale = ContentScale.FillBounds, contentScale = ContentScale.FillBounds,
@ -88,7 +106,7 @@ fun GenreCard(
.background(background), .background(background),
) { ) {
Text( Text(
text = genre?.name ?: "", text = name ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@ -112,7 +130,6 @@ private fun GenreCardPreview() {
UUID.randomUUID(), UUID.randomUUID(),
"Adventure", "Adventure",
null, null,
Color.Black,
) )
GenreCard( GenreCard(
genre = genre, genre = genre,

View file

@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -126,21 +125,7 @@ fun SeasonCard(
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
var focusedAfterDelay by remember { mutableStateOf(false) } val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val hideOverlayDelay = 500L
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = imageHeight * aspectRationToUse val width = imageHeight * aspectRationToUse
val height = imageWidth * (1f / 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.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -41,6 +41,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@ -102,51 +103,22 @@ class GenreViewModel
.execute(api, request) .execute(api, request)
.content.items .content.items
.map { .map {
Genre(it.id, it.name ?: "", null, Color.Black) Genre(it.id, it.name ?: "", null)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@GenreViewModel.genres.value = genres this@GenreViewModel.genres.value = genres
loading.value = LoadingState.Success loading.value = LoadingState.Success
} }
val genreToUrl = ConcurrentHashMap<UUID, String?>() val genreToUrl =
val semaphore = Semaphore(4) getGenreImageMap(
genres api = api,
.map { genre -> scope = viewModelScope,
viewModelScope.async(Dispatchers.IO) { imageUrlService = imageUrlService,
semaphore.withPermit { genres = genres.map { it.id },
val item = parentId = itemId,
GetItemsRequestHandler includeItemTypes = includeItemTypes,
.execute( cardWidthPx = cardWidthPx,
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 genresWithImages = val genresWithImages =
genres.map { genres.map {
it.copy( 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( data class Genre(
val id: UUID, val id: UUID,
val name: String, val name: String,
val imageUrl: String?, val imageUrl: String?,
val color: Color,
) : CardGridItem { ) : CardGridItem {
override val gridId: String get() = id.toString() override val gridId: String get() = id.toString()
override val playable: Boolean = false override val playable: Boolean = false

View file

@ -1,9 +1,7 @@
package com.github.damontecres.wholphin.ui.main package com.github.damontecres.wholphin.ui.main
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf 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.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -48,16 +47,19 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.UserPreferences 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
import com.github.damontecres.wholphin.ui.cards.BannerCard 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.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EpisodeName import com.github.damontecres.wholphin.ui.components.EpisodeName
import com.github.damontecres.wholphin.ui.components.ErrorMessage 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.LoadingPage
import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel 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.nav.Destination
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
import com.github.damontecres.wholphin.ui.playback.playable 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.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
@ -94,12 +97,10 @@ fun HomePage(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init() viewModel.init()
} }
val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) val state by viewModel.state.collectAsState()
val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading) val loading = state.loadingState
val watchingRows by viewModel.watchingRows.observeAsState(listOf()) val refreshing = state.refreshState
val latestRows by viewModel.latestRows.observeAsState(listOf()) val homeRows = state.homeRows
val homeRows = remember(watchingRows, latestRows) { watchingRows + latestRows }
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
@ -200,6 +201,9 @@ fun HomePageContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onFocusPosition: ((RowColumn) -> Unit)? = null, onFocusPosition: ((RowColumn) -> Unit)? = null,
loadingState: LoadingState? = null, loadingState: LoadingState? = null,
listState: LazyListState = rememberLazyListState(),
takeFocus: Boolean = true,
showEmptyRows: Boolean = false,
) { ) {
var position by rememberPosition() var position by rememberPosition()
val focusedItem = val focusedItem =
@ -207,37 +211,37 @@ fun HomePageContent(
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
} }
val listState = rememberLazyListState()
val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } } val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
var firstFocused by remember { mutableStateOf(false) } var firstFocused by remember { mutableStateOf(false) }
LaunchedEffect(homeRows) { if (takeFocus) {
if (!firstFocused && homeRows.isNotEmpty()) { LaunchedEffect(homeRows) {
if (position.row >= 0) { if (!firstFocused && homeRows.isNotEmpty()) {
val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex) if (position.row >= 0) {
rowFocusRequesters.getOrNull(index)?.tryRequestFocus() val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex)
firstFocused = true rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
} else { firstFocused = true
// Waiting for the first home row to load, then focus on it } else {
homeRows // Waiting for the first home row to load, then focus on it
.indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } homeRows
?.let { .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
rowFocusRequesters[it].tryRequestFocus() ?.let {
firstFocused = true rowFocusRequesters[it].tryRequestFocus()
delay(50) firstFocused = true
listState.scrollToItem(it) delay(50)
} listState.scrollToItem(it)
}
}
} }
} }
} }
LaunchedEffect(position) {
if (position.row >= 0) {
listState.animateScrollToItem(position.row)
}
}
LaunchedEffect(onUpdateBackdrop, focusedItem) { LaunchedEffect(onUpdateBackdrop, focusedItem) {
focusedItem?.let { onUpdateBackdrop.invoke(it) } 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) { Box(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
HomePageHeader( HomePageHeader(
@ -247,6 +251,12 @@ fun HomePageContent(
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp) .padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
.fillMaxHeight(.33f), .fillMaxHeight(.33f),
) )
val density = LocalDensity.current
val spaceAbovePx =
with(density) {
// The size of the row titles & spacing
50.dp.toPx()
}
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
CompositionLocalProvider( CompositionLocalProvider(
LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
@ -263,61 +273,32 @@ fun HomePageContent(
.focusRestorer(), .focusRestorer(),
) { ) {
itemsIndexed(homeRows) { rowIndex, row -> itemsIndexed(homeRows) { rowIndex, row ->
CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { CompositionLocalProvider(
LocalBringIntoViewSpec provides defaultBringIntoViewSpec,
) {
when (val r = row) { when (val r = row) {
is HomeRowLoadingState.Loading, is HomeRowLoadingState.Loading,
is HomeRowLoadingState.Pending, is HomeRowLoadingState.Pending,
-> { -> {
Column( FocusableItemRow(
verticalArrangement = Arrangement.spacedBy(8.dp), title = r.title,
subtitle = stringResource(R.string.loading),
modifier = Modifier.animateItem(), 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 -> { is HomeRowLoadingState.Error -> {
var focused by remember { mutableStateOf(false) } FocusableItemRow(
Column( title = r.title,
verticalArrangement = Arrangement.spacedBy(8.dp), subtitle = r.localizedMessage,
modifier = isError = true,
Modifier modifier = Modifier.animateItem(),
.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,
)
}
} }
is HomeRowLoadingState.Success -> { is HomeRowLoadingState.Success -> {
if (row.items.isNotEmpty()) { if (row.items.isNotEmpty()) {
val viewOptions = row.viewOptions
ItemRow( ItemRow(
title = row.title, title = row.title,
items = row.items, items = row.items,
@ -336,26 +317,20 @@ fun HomePageContent(
.focusGroup() .focusGroup()
.focusRequester(rowFocusRequesters[rowIndex]) .focusRequester(rowFocusRequesters[rowIndex])
.animateItem(), .animateItem(),
horizontalPadding = viewOptions.spacing.dp,
cardContent = { index, item, cardModifier, onClick, onLongClick -> cardContent = { index, item, cardModifier, onClick, onLongClick ->
BannerCard( HomePageCardContent(
name = item?.data?.seriesName ?: item?.name, index = index,
item = item, 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, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
viewOptions = viewOptions,
modifier = modifier =
cardModifier cardModifier
.onFocusChanged { .onFocusChanged {
if (it.isFocused) { if (it.isFocused) {
position = position =
RowColumn(rowIndex, index) RowColumn(rowIndex, index)
// item?.let(onUpdateBackdrop)
} }
if (it.isFocused && onFocusPosition != null) { if (it.isFocused && onFocusPosition != null) {
val nonEmptyRowBefore = val nonEmptyRowBefore =
@ -382,11 +357,15 @@ fun HomePageContent(
} }
return@onKeyEvent false 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, subtitle = if (isEpisode) dto?.name else null,
overview = dto?.overview, overview = dto?.overview,
overviewTwoLines = isEpisode, overviewTwoLines = isEpisode,
quickDetails = item?.ui?.quickDetails, quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""),
timeRemaining = item?.timeRemainingOrRuntime, timeRemaining = item?.timeRemainingOrRuntime,
modifier = modifier, 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 package com.github.damontecres.wholphin.ui.main
import android.content.Context import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem 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.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.launchIO 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.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers 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.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext 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 timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -41,115 +44,120 @@ class HomeViewModel
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
val api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val navDrawerItemRepository: NavDrawerItemRepository, val navDrawerItemRepository: NavDrawerItemRepository,
val mediaReportService: MediaReportService, val mediaReportService: MediaReportService,
private val homeSettingsService: HomeSettingsService,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val datePlayedService: DatePlayedService, private val datePlayedService: DatePlayedService,
private val latestNextUpService: LatestNextUpService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
) : ViewModel() { ) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending) private val _state = MutableStateFlow(HomeState.EMPTY)
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending) val state: StateFlow<HomeState> = _state
val watchingRows = MutableLiveData<List<HomeRowLoadingState>>(listOf())
val latestRows = MutableLiveData<List<HomeRowLoadingState>>(listOf())
private lateinit var preferences: UserPreferences
init { init {
datePlayedService.invalidateAll() datePlayedService.invalidateAll()
init() // init()
} }
fun init() { fun init() {
viewModelScope.launch( viewModelScope.launchIO {
Dispatchers.IO +
LoadingExceptionHandler(
loadingState,
"Error loading home page",
),
) {
Timber.d("init HomeViewModel") 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 { 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 -> serverRepository.currentUserDto.value?.let { userDto ->
val includedIds = val settings =
navDrawerItemRepository homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
.getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) val state = state.value
.filter { it is ServerNavDrawerItem }
.map { (it as ServerNavDrawerItem).itemId } // Refreshing if a load has already occurred and the rows haven't significantly changed
val resume = latestNextUpService.getResume(userDto.id, limit, true) val refresh =
val nextUp = state.loadingState == LoadingState.Success && state.settings == settings
latestNextUpService.getNextUp(
userDto.id, val semaphore = Semaphore(4)
limit,
prefs.enableRewatchingNextUp, val watchingRowIndexes =
false, settings.rows
prefs.maxDaysNextUp, .mapIndexedNotNull { index, row ->
) if (isWatchingRow(row.config)) index else null
val watching = }
buildList { val deferred =
if (prefs.combineContinueNext) { settings.rows
val items = latestNextUpService.buildCombined(resume, nextUp) // Load the watching rows first
add( .sortedByDescending { isWatchingRow(it.config) }
HomeRowLoadingState.Success( .map { row ->
title = context.getString(R.string.continue_watching), viewModelScope.async(Dispatchers.IO) {
items = items, semaphore.withPermit {
), Timber.v("Fetching row: %s", row)
) try {
} else { homeSettingsService.fetchDataForRow(
if (resume.isNotEmpty()) { row = row.config,
add( scope = viewModelScope,
HomeRowLoadingState.Success( prefs = prefs,
title = context.getString(R.string.continue_watching), userDto = userDto,
items = resume, libraries = libraries,
), limit = prefs.maxItemsPerRow,
) )
} } catch (ex: Exception) {
if (nextUp.isNotEmpty()) { Timber.e(ex, "Error on row %s", row)
add( HomeRowLoadingState.Error(row.title, exception = ex)
HomeRowLoadingState.Success( }
title = context.getString(R.string.next_up), }
items = nextUp,
),
)
} }
} }
}
val latest = latestNextUpService.getLatest(userDto, limit, includedIds) if (refresh && state.homeRows.isNotEmpty() && watchingRowIndexes.isNotEmpty()) {
val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } // Replace watching rows first
Timber.v("Refreshing rows: %s", watchingRowIndexes)
withContext(Dispatchers.Main) { val rows =
this@HomeViewModel.watchingRows.value = watching deferred
if (reload) { .filterIndexed { index, _ -> index in watchingRowIndexes }
this@HomeViewModel.latestRows.value = pendingLatest .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 rows = deferred.awaitAll()
val loadedLatest = latestNextUpService.loadLatest(latest) Timber.v("Got all rows")
this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) _state.update {
it.copy(
loadingState = LoadingState.Success,
refreshState = LoadingState.Success,
homeRows = rows,
)
}
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex) Timber.e(ex, "Exception during home page loading")
if (!reload) { if (state.value.loadingState == LoadingState.Success) {
loadingState.setValueOnMain(LoadingState.Error(ex))
} else {
showToast(context, "Error refreshing home: ${ex.localizedMessage}") showToast(context, "Error refreshing home: ${ex.localizedMessage}")
} else {
_state.update {
it.copy(loadingState = LoadingState.Error(ex))
}
} }
} }
} }
@ -182,16 +190,27 @@ class HomeViewModel
} }
} }
val supportedLatestCollectionTypes = data class HomeState(
setOf( val loadingState: LoadingState,
CollectionType.MOVIES, val refreshState: LoadingState,
CollectionType.TVSHOWS, val homeRows: List<HomeRowLoadingState>,
CollectionType.HOMEVIDEOS, val settings: HomePageResolvedSettings,
// Exclude Live TV because a recording folder view will be used instead ) {
null, // Recordings & mixed collection types companion object {
) val EMPTY =
HomeState(
LoadingState.Pending,
LoadingState.Pending,
listOf(),
HomePageResolvedSettings.EMPTY,
)
}
}
data class LatestData( /**
val title: String, * Whether a row is a "is watching" type
val request: GetLatestMediaRequest, */
) 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, val id: Long = 0L,
) : Destination() ) : Destination()
@Serializable
data object HomeSettings : Destination(true)
@Serializable @Serializable
data class Settings( data class Settings(
val screen: PreferenceScreenOption, 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.discover.DiscoverPage
import com.github.damontecres.wholphin.ui.main.HomePage import com.github.damontecres.wholphin.ui.main.HomePage
import com.github.damontecres.wholphin.ui.main.SearchPage 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.playback.PlaybackPage
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage 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.PlaybackList,
is Destination.Playback, is Destination.Playback,
-> { -> {

View file

@ -34,6 +34,14 @@ val supportedCollectionTypes =
null, // Mixed null, // Mixed
) )
val supportedHomeCollectionTypes =
setOf(
CollectionType.MOVIES,
CollectionType.TVSHOWS,
CollectionType.HOMEVIDEOS,
null, // Mixed
)
val supportedPlayableTypes = val supportedPlayableTypes =
setOf( setOf(
BaseItemKind.MOVIE, BaseItemKind.MOVIE,

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.util package com.github.damontecres.wholphin.util
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
/** /**
* Generic state for loading something from the API * Generic state for loading something from the API
@ -62,6 +63,7 @@ sealed interface HomeRowLoadingState {
data class Success( data class Success(
override val title: String, override val title: String,
val items: List<BaseItem?>, val items: List<BaseItem?>,
val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowLoadingState ) : HomeRowLoadingState
data class Error( data class Error(

View file

@ -51,4 +51,6 @@
<string name="fa_clock" translatable="false">&#xf017;</string> <string name="fa_clock" translatable="false">&#xf017;</string>
<string name="fa_bell" translatable="false">&#xf0f3;</string> <string name="fa_bell" translatable="false">&#xf0f3;</string>
<string name="fa_check" translatable="false">&#xf00c;</string> <string name="fa_check" translatable="false">&#xf00c;</string>
<string name="fa_cloud_arrow_up" translatable="false">&#xf0ee;</string>
<string name="fa_cloud_arrow_down" translatable="false">&#xf0ed;</string>
</resources> </resources>

View file

@ -498,6 +498,29 @@
<string name="no_limit">No limit</string> <string name="no_limit">No limit</string>
<string name="max_days_next_up">Max days in Next Up</string> <string name="max_days_next_up">Max days in Next Up</string>
<string name="add_row">Add row</string>
<string name="genres_in">Genres in %1$s</string>
<string name="recently_released_in">Recently released in %1$s</string>
<string name="height">Height</string>
<string name="apply_all_rows">Apply to all rows</string>
<string name="customize_home">Customize home page</string>
<string name="home_rows">Home rows</string>
<string name="load_from_server">Load from server</string>
<string name="save_to_server">Save to server</string>
<string name="load_from_web_client">Load from web client</string>
<string name="use_series">Use series image</string>
<string name="add_row_for">Add row for %1$s</string>
<string name="overwrite_server_settings">Overwrite settings on server?</string>
<string name="overwrite_local_settings">Overwrite local settings?</string>
<string name="for_episodes">For episodes</string>
<string name="suggestions_for">Suggestions for %1$s</string>
<string name="increase_all_cards_size">Increase size for all cards</string>
<string name="decrease_all_cards_size">Decrease size for all cards</string>
<string name="use_thumb_images">Use thumb images</string>
<string name="settings_saved">Settings saved</string>
<string name="display_presets">Display presets</string>
<string name="display_presets_description">Built-in presets to quickly style all rows</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>
<item>Lowest</item> <item>Lowest</item>

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