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