mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +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(
|
data class BaseItem(
|
||||||
val data: BaseItemDto,
|
val data: BaseItemDto,
|
||||||
val useSeriesForPrimary: Boolean,
|
val useSeriesForPrimary: Boolean,
|
||||||
|
val imageUrlOverride: String? = null,
|
||||||
) : CardGridItem {
|
) : CardGridItem {
|
||||||
val id get() = data.id
|
val id get() = data.id
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
setter = { prefs, _ -> prefs },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val CustomizeHome =
|
||||||
|
AppDestinationPreference<AppPreferences>(
|
||||||
|
title = R.string.customize_home,
|
||||||
|
destination = Destination.HomeSettings,
|
||||||
|
)
|
||||||
|
|
||||||
val SendCrashReports =
|
val SendCrashReports =
|
||||||
AppSwitchPreference<AppPreferences>(
|
AppSwitchPreference<AppPreferences>(
|
||||||
title = R.string.send_crash_reports,
|
title = R.string.send_crash_reports,
|
||||||
|
|
@ -1000,10 +1006,6 @@ val basicPreferences =
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.SignInAuto,
|
AppPreference.SignInAuto,
|
||||||
AppPreference.HomePageItems,
|
|
||||||
AppPreference.CombineContinueNext,
|
|
||||||
AppPreference.RewatchNextUp,
|
|
||||||
AppPreference.MaxDaysNextUp,
|
|
||||||
AppPreference.PlayThemeMusic,
|
AppPreference.PlayThemeMusic,
|
||||||
AppPreference.RememberSelectedTab,
|
AppPreference.RememberSelectedTab,
|
||||||
AppPreference.SubtitleStyle,
|
AppPreference.SubtitleStyle,
|
||||||
|
|
@ -1034,6 +1036,7 @@ val basicPreferences =
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.RequireProfilePin,
|
AppPreference.RequireProfilePin,
|
||||||
|
AppPreference.CustomizeHome,
|
||||||
AppPreference.UserPinnedNavDrawerItems,
|
AppPreference.UserPinnedNavDrawerItems,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1099,6 +1102,7 @@ val advancedPreferences =
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.ShowClock,
|
AppPreference.ShowClock,
|
||||||
|
AppPreference.CombineContinueNext,
|
||||||
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
||||||
// AppPreference.NavDrawerSwitchOnFocus,
|
// AppPreference.NavDrawerSwitchOnFocus,
|
||||||
AppPreference.ControllerTimeout,
|
AppPreference.ControllerTimeout,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -47,7 +48,12 @@ class BackdropService
|
||||||
|
|
||||||
suspend fun submit(item: BaseItem) =
|
suspend fun submit(item: BaseItem) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
|
val imageUrl =
|
||||||
|
if (item.type == BaseItemKind.GENRE) {
|
||||||
|
item.imageUrlOverride
|
||||||
|
} else {
|
||||||
|
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
|
||||||
|
}
|
||||||
submit(item.id.toString(), imageUrl)
|
submit(item.id.toString(), imageUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.main.LatestData
|
|
||||||
import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes
|
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
|
@ -21,6 +19,7 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
import org.jellyfin.sdk.model.api.UserDto
|
import org.jellyfin.sdk.model.api.UserDto
|
||||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||||
|
|
@ -44,6 +43,7 @@ class LatestNextUpService
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
includeEpisodes: Boolean,
|
includeEpisodes: Boolean,
|
||||||
|
useSeriesForPrimary: Boolean = true,
|
||||||
): List<BaseItem> {
|
): List<BaseItem> {
|
||||||
val request =
|
val request =
|
||||||
GetResumeItemsRequest(
|
GetResumeItemsRequest(
|
||||||
|
|
@ -66,7 +66,7 @@ class LatestNextUpService
|
||||||
.getResumeItems(request)
|
.getResumeItems(request)
|
||||||
.content
|
.content
|
||||||
.items
|
.items
|
||||||
.map { BaseItem.from(it, api, true) }
|
.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,6 +76,7 @@ class LatestNextUpService
|
||||||
enableRewatching: Boolean,
|
enableRewatching: Boolean,
|
||||||
enableResumable: Boolean,
|
enableResumable: Boolean,
|
||||||
maxDays: Int,
|
maxDays: Int,
|
||||||
|
useSeriesForPrimary: Boolean = true,
|
||||||
): List<BaseItem> {
|
): List<BaseItem> {
|
||||||
val nextUpDateCutoff =
|
val nextUpDateCutoff =
|
||||||
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
|
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
|
||||||
|
|
@ -96,7 +97,7 @@ class LatestNextUpService
|
||||||
.getNextUp(request)
|
.getNextUp(request)
|
||||||
.content
|
.content
|
||||||
.items
|
.items
|
||||||
.map { BaseItem.from(it, api, true) }
|
.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
||||||
return nextUp
|
return nextUp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,3 +193,17 @@ class LatestNextUpService
|
||||||
return@withContext result
|
return@withContext result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val supportedLatestCollectionTypes =
|
||||||
|
setOf(
|
||||||
|
CollectionType.MOVIES,
|
||||||
|
CollectionType.TVSHOWS,
|
||||||
|
CollectionType.HOMEVIDEOS,
|
||||||
|
// Exclude Live TV because a recording folder view will be used instead
|
||||||
|
null, // Recordings & mixed collection types
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LatestData(
|
||||||
|
val title: String,
|
||||||
|
val request: GetLatestMediaRequest,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,7 @@ class UserSwitchListener
|
||||||
private val seerrServerRepository: SeerrServerRepository,
|
private val seerrServerRepository: SeerrServerRepository,
|
||||||
private val seerrServerDao: SeerrServerDao,
|
private val seerrServerDao: SeerrServerDao,
|
||||||
private val seerrApi: SeerrApi,
|
private val seerrApi: SeerrApi,
|
||||||
|
private val homeSettingsService: HomeSettingsService,
|
||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
context as AppCompatActivity
|
context as AppCompatActivity
|
||||||
|
|
@ -233,41 +234,58 @@ class UserSwitchListener
|
||||||
serverRepository.currentUser.asFlow().collect { user ->
|
serverRepository.currentUser.asFlow().collect { user ->
|
||||||
Timber.d("New user")
|
Timber.d("New user")
|
||||||
seerrServerRepository.clear()
|
seerrServerRepository.clear()
|
||||||
|
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
seerrServerDao
|
// Check for home settings
|
||||||
.getUsersByJellyfinUser(user.rowId)
|
launchIO {
|
||||||
.firstOrNull()
|
homeSettingsService.loadCurrentSettings(user.id)
|
||||||
?.let { seerrUser ->
|
}
|
||||||
val server = seerrServerDao.getServer(seerrUser.serverId)?.server
|
// Check for seerr server
|
||||||
if (server != null) {
|
launchIO {
|
||||||
Timber.i("Found a seerr user & server")
|
seerrServerDao
|
||||||
seerrApi.update(server.url, seerrUser.credential)
|
.getUsersByJellyfinUser(user.rowId)
|
||||||
val userConfig =
|
.firstOrNull()
|
||||||
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
|
?.let { seerrUser ->
|
||||||
try {
|
val server =
|
||||||
login(
|
seerrServerDao.getServer(seerrUser.serverId)?.server
|
||||||
seerrApi.api,
|
if (server != null) {
|
||||||
seerrUser.authMethod,
|
Timber.i("Found a seerr user & server")
|
||||||
seerrUser.username,
|
seerrApi.update(server.url, seerrUser.credential)
|
||||||
seerrUser.password,
|
val userConfig =
|
||||||
)
|
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
|
||||||
} catch (ex: Exception) {
|
try {
|
||||||
Timber.w(ex, "Error logging into %s", server.url)
|
login(
|
||||||
seerrServerRepository.clear()
|
seerrApi.api,
|
||||||
return@let
|
seerrUser.authMethod,
|
||||||
|
seerrUser.username,
|
||||||
|
seerrUser.password,
|
||||||
|
)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.w(
|
||||||
|
ex,
|
||||||
|
"Error logging into %s",
|
||||||
|
server.url,
|
||||||
|
)
|
||||||
|
seerrServerRepository.clear()
|
||||||
|
return@let
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
seerrApi.api.usersApi.authMeGet()
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.w(
|
||||||
|
ex,
|
||||||
|
"Error logging into %s",
|
||||||
|
server.url,
|
||||||
|
)
|
||||||
|
seerrServerRepository.clear()
|
||||||
|
return@let
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
seerrServerRepository.set(server, seerrUser, userConfig)
|
||||||
try {
|
}
|
||||||
seerrApi.api.usersApi.authMeGet()
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.w(ex, "Error logging into %s", server.url)
|
|
||||||
seerrServerRepository.clear()
|
|
||||||
return@let
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seerrServerRepository.set(server, seerrUser, userConfig)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,11 +85,8 @@ class SuggestionsWorker
|
||||||
views
|
views
|
||||||
.mapNotNull { view ->
|
.mapNotNull { view ->
|
||||||
val itemKind =
|
val itemKind =
|
||||||
when (view.collectionType) {
|
getTypeForCollection(view.collectionType)
|
||||||
CollectionType.MOVIES -> BaseItemKind.MOVIE
|
?: return@mapNotNull null
|
||||||
CollectionType.TVSHOWS -> BaseItemKind.SERIES
|
|
||||||
else -> return@mapNotNull null
|
|
||||||
}
|
|
||||||
async(Dispatchers.IO) {
|
async(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
Timber.v("Fetching suggestions for view %s", view.id)
|
Timber.v("Fetching suggestions for view %s", view.id)
|
||||||
|
|
@ -267,5 +264,12 @@ class SuggestionsWorker
|
||||||
const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker"
|
const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker"
|
||||||
const val PARAM_USER_ID = "userId"
|
const val PARAM_USER_ID = "userId"
|
||||||
const val PARAM_SERVER_ID = "serverId"
|
const val PARAM_SERVER_ID = "serverId"
|
||||||
|
|
||||||
|
fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? =
|
||||||
|
when (collectionType) {
|
||||||
|
CollectionType.MOVIES -> BaseItemKind.MOVIE
|
||||||
|
CollectionType.TVSHOWS -> BaseItemKind.SERIES
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,6 @@ class TvProviderWorker
|
||||||
getPotentialItems(
|
getPotentialItems(
|
||||||
userId,
|
userId,
|
||||||
prefs.homePagePreferences.enableRewatchingNextUp,
|
prefs.homePagePreferences.enableRewatchingNextUp,
|
||||||
prefs.homePagePreferences.combineContinueNext,
|
|
||||||
prefs.homePagePreferences.maxDaysNextUp,
|
prefs.homePagePreferences.maxDaysNextUp,
|
||||||
)
|
)
|
||||||
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
|
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
|
||||||
|
|
@ -145,7 +144,6 @@ class TvProviderWorker
|
||||||
private suspend fun getPotentialItems(
|
private suspend fun getPotentialItems(
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
enableRewatching: Boolean,
|
enableRewatching: Boolean,
|
||||||
combineContinueNext: Boolean,
|
|
||||||
maxDaysNextUp: Int,
|
maxDaysNextUp: Int,
|
||||||
): List<BaseItem> {
|
): List<BaseItem> {
|
||||||
val resumeItems = latestNextUpService.getResume(userId, 10, true)
|
val resumeItems = latestNextUpService.getResume(userId, 10, true)
|
||||||
|
|
@ -154,11 +152,7 @@ class TvProviderWorker
|
||||||
latestNextUpService
|
latestNextUpService
|
||||||
.getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp)
|
.getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp)
|
||||||
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
|
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
|
||||||
return if (combineContinueNext) {
|
return latestNextUpService.buildCombined(resumeItems, nextUpItems)
|
||||||
latestNextUpService.buildCombined(resumeItems, nextUpItems)
|
|
||||||
} else {
|
|
||||||
resumeItems + nextUpItems
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> =
|
private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> =
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,10 @@ val PhotoItemFields =
|
||||||
)
|
)
|
||||||
|
|
||||||
object Cards {
|
object Cards {
|
||||||
val height2x3 = 172.dp
|
const val HEIGHT_2X3_DP = 172
|
||||||
val heightEpisode = height2x3 * .75f
|
val height2x3 = HEIGHT_2X3_DP.dp
|
||||||
|
val HEIGHT_EPISODE = (HEIGHT_2X3_DP * .75f).toInt().let { it - it % 4 }
|
||||||
|
val heightEpisode = HEIGHT_EPISODE.dp
|
||||||
val playedPercentHeight = 6.dp
|
val playedPercentHeight = 6.dp
|
||||||
val serverUserCircle = height2x3 * .75f
|
val serverUserCircle = height2x3 * .75f
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
package com.github.damontecres.wholphin.ui.cards
|
package com.github.damontecres.wholphin.ui.cards
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -24,6 +28,7 @@ import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -41,6 +46,7 @@ import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -60,6 +66,8 @@ fun BannerCard(
|
||||||
cardHeight: Dp = 120.dp,
|
cardHeight: Dp = 120.dp,
|
||||||
aspectRatio: Float = AspectRatios.WIDE,
|
aspectRatio: Float = AspectRatios.WIDE,
|
||||||
interactionSource: MutableInteractionSource? = null,
|
interactionSource: MutableInteractionSource? = null,
|
||||||
|
imageType: ImageType = ImageType.PRIMARY,
|
||||||
|
imageContentScale: ContentScale = ContentScale.FillBounds,
|
||||||
) {
|
) {
|
||||||
val imageUrlService = LocalImageUrlService.current
|
val imageUrlService = LocalImageUrlService.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
@ -74,14 +82,15 @@ fun BannerCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val imageUrl =
|
val imageUrl =
|
||||||
remember(item, fillHeight) {
|
remember(item, fillHeight, imageType) {
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
imageUrlService.getItemImageUrl(
|
item.imageUrlOverride
|
||||||
item,
|
?: imageUrlService.getItemImageUrl(
|
||||||
ImageType.PRIMARY,
|
item,
|
||||||
fillWidth = null,
|
imageType,
|
||||||
fillHeight = fillHeight,
|
fillWidth = null,
|
||||||
)
|
fillHeight = fillHeight,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +116,7 @@ fun BannerCard(
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.FillBounds,
|
contentScale = imageContentScale,
|
||||||
onError = { imageError = true },
|
onError = { imageError = true },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
|
@ -181,3 +190,82 @@ fun BannerCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BannerCardWithTitle(
|
||||||
|
title: String?,
|
||||||
|
subtitle: String?,
|
||||||
|
item: BaseItem?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
cornerText: String? = null,
|
||||||
|
played: Boolean = false,
|
||||||
|
favorite: Boolean = false,
|
||||||
|
playPercent: Double = 0.0,
|
||||||
|
cardHeight: Dp = 120.dp,
|
||||||
|
aspectRatio: Float = AspectRatios.WIDE,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
imageType: ImageType = ImageType.PRIMARY,
|
||||||
|
imageContentScale: ContentScale = ContentScale.FillBounds,
|
||||||
|
) {
|
||||||
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
|
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||||
|
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
||||||
|
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||||
|
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||||
|
val width = cardHeight * aspectRationToUse
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||||
|
modifier = modifier.width(width),
|
||||||
|
) {
|
||||||
|
BannerCard(
|
||||||
|
name = null,
|
||||||
|
item = item,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
modifier = Modifier,
|
||||||
|
cornerText = cornerText,
|
||||||
|
played = played,
|
||||||
|
favorite = favorite,
|
||||||
|
playPercent = playPercent,
|
||||||
|
cardHeight = cardHeight,
|
||||||
|
aspectRatio = aspectRatio,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
imageType = imageType,
|
||||||
|
imageContentScale = imageContentScale,
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(bottom = spaceBelow)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title ?: "",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.enableMarquee(focusedAfterDelay),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle ?: "",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.enableMarquee(focusedAfterDelay),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,29 @@ fun GenreCard(
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) = GenreCard(
|
||||||
|
genreId = genre?.id,
|
||||||
|
name = genre?.name,
|
||||||
|
imageUrl = genre?.imageUrl,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
modifier = modifier,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GenreCard(
|
||||||
|
genreId: UUID?,
|
||||||
|
name: String?,
|
||||||
|
imageUrl: String?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
) {
|
) {
|
||||||
val background = rememberIdColor(genre?.id).copy(alpha = .6f)
|
val background = rememberIdColor(genreId).copy(alpha = .6f)
|
||||||
Card(
|
Card(
|
||||||
modifier =
|
modifier = modifier,
|
||||||
modifier,
|
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
|
|
@ -63,12 +81,12 @@ fun GenreCard(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.clip(RoundedCornerShape(8.dp)),
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
) {
|
) {
|
||||||
if (genre?.imageUrl.isNotNullOrBlank()) {
|
if (imageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model =
|
model =
|
||||||
ImageRequest
|
ImageRequest
|
||||||
.Builder(LocalContext.current)
|
.Builder(LocalContext.current)
|
||||||
.data(genre.imageUrl)
|
.data(imageUrl)
|
||||||
.crossfade(true)
|
.crossfade(true)
|
||||||
.build(),
|
.build(),
|
||||||
contentScale = ContentScale.FillBounds,
|
contentScale = ContentScale.FillBounds,
|
||||||
|
|
@ -88,7 +106,7 @@ fun GenreCard(
|
||||||
.background(background),
|
.background(background),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = genre?.name ?: "",
|
text = name ?: "",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
|
@ -112,7 +130,6 @@ private fun GenreCardPreview() {
|
||||||
UUID.randomUUID(),
|
UUID.randomUUID(),
|
||||||
"Adventure",
|
"Adventure",
|
||||||
null,
|
null,
|
||||||
Color.Black,
|
|
||||||
)
|
)
|
||||||
GenreCard(
|
GenreCard(
|
||||||
genre = genre,
|
genre = genre,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
|
@ -126,21 +125,7 @@ fun SeasonCard(
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
||||||
var focusedAfterDelay by remember { mutableStateOf(false) }
|
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||||
|
|
||||||
val hideOverlayDelay = 500L
|
|
||||||
if (focused) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
delay(hideOverlayDelay)
|
|
||||||
if (focused) {
|
|
||||||
focusedAfterDelay = true
|
|
||||||
} else {
|
|
||||||
focusedAfterDelay = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
focusedAfterDelay = false
|
|
||||||
}
|
|
||||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||||
val width = imageHeight * aspectRationToUse
|
val width = imageHeight * aspectRationToUse
|
||||||
val height = imageWidth * (1f / aspectRationToUse)
|
val height = imageWidth * (1f / aspectRationToUse)
|
||||||
|
|
@ -212,3 +197,22 @@ fun SeasonCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a [androidx.compose.runtime.State] which represents if the item has been focused for a while
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberFocusedAfterDelay(interactionSource: MutableInteractionSource): androidx.compose.runtime.State<Boolean> {
|
||||||
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
|
val state = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(focused) {
|
||||||
|
if (!focused) {
|
||||||
|
state.value = false
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
delay(500L)
|
||||||
|
state.value = true
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -41,6 +41,7 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
|
@ -102,51 +103,22 @@ class GenreViewModel
|
||||||
.execute(api, request)
|
.execute(api, request)
|
||||||
.content.items
|
.content.items
|
||||||
.map {
|
.map {
|
||||||
Genre(it.id, it.name ?: "", null, Color.Black)
|
Genre(it.id, it.name ?: "", null)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@GenreViewModel.genres.value = genres
|
this@GenreViewModel.genres.value = genres
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
}
|
}
|
||||||
val genreToUrl = ConcurrentHashMap<UUID, String?>()
|
val genreToUrl =
|
||||||
val semaphore = Semaphore(4)
|
getGenreImageMap(
|
||||||
genres
|
api = api,
|
||||||
.map { genre ->
|
scope = viewModelScope,
|
||||||
viewModelScope.async(Dispatchers.IO) {
|
imageUrlService = imageUrlService,
|
||||||
semaphore.withPermit {
|
genres = genres.map { it.id },
|
||||||
val item =
|
parentId = itemId,
|
||||||
GetItemsRequestHandler
|
includeItemTypes = includeItemTypes,
|
||||||
.execute(
|
cardWidthPx = cardWidthPx,
|
||||||
api,
|
)
|
||||||
GetItemsRequest(
|
|
||||||
parentId = itemId,
|
|
||||||
recursive = true,
|
|
||||||
limit = 1,
|
|
||||||
sortBy = listOf(ItemSortBy.RANDOM),
|
|
||||||
fields = listOf(ItemFields.GENRES),
|
|
||||||
imageTypes = listOf(ImageType.BACKDROP),
|
|
||||||
imageTypeLimit = 1,
|
|
||||||
includeItemTypes = includeItemTypes,
|
|
||||||
genreIds = listOf(genre.id),
|
|
||||||
enableTotalRecordCount = false,
|
|
||||||
),
|
|
||||||
).content.items
|
|
||||||
.firstOrNull()
|
|
||||||
if (item != null) {
|
|
||||||
genreToUrl[genre.id] =
|
|
||||||
imageUrlService.getItemImageUrl(
|
|
||||||
itemId = item.id,
|
|
||||||
itemType = item.type,
|
|
||||||
seriesId = null,
|
|
||||||
useSeriesForPrimary = true,
|
|
||||||
imageType = ImageType.BACKDROP,
|
|
||||||
imageTags = item.imageTags.orEmpty(),
|
|
||||||
fillWidth = cardWidthPx,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.awaitAll()
|
|
||||||
val genresWithImages =
|
val genresWithImages =
|
||||||
genres.map {
|
genres.map {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
@ -171,11 +143,62 @@ class GenreViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getGenreImageMap(
|
||||||
|
api: ApiClient,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
imageUrlService: ImageUrlService,
|
||||||
|
genres: List<UUID>,
|
||||||
|
parentId: UUID,
|
||||||
|
includeItemTypes: List<BaseItemKind>?,
|
||||||
|
cardWidthPx: Int?,
|
||||||
|
): Map<UUID, String?> {
|
||||||
|
val genreToUrl = ConcurrentHashMap<UUID, String?>()
|
||||||
|
val semaphore = Semaphore(4)
|
||||||
|
genres
|
||||||
|
.map { genreId ->
|
||||||
|
scope.async(Dispatchers.IO) {
|
||||||
|
semaphore.withPermit {
|
||||||
|
val item =
|
||||||
|
GetItemsRequestHandler
|
||||||
|
.execute(
|
||||||
|
api,
|
||||||
|
GetItemsRequest(
|
||||||
|
parentId = parentId,
|
||||||
|
recursive = true,
|
||||||
|
limit = 1,
|
||||||
|
sortBy = listOf(ItemSortBy.RANDOM),
|
||||||
|
fields = listOf(ItemFields.GENRES),
|
||||||
|
imageTypes = listOf(ImageType.BACKDROP),
|
||||||
|
imageTypeLimit = 1,
|
||||||
|
includeItemTypes = includeItemTypes,
|
||||||
|
genreIds = listOf(genreId),
|
||||||
|
enableTotalRecordCount = false,
|
||||||
|
),
|
||||||
|
).content.items
|
||||||
|
.firstOrNull()
|
||||||
|
if (item != null) {
|
||||||
|
genreToUrl[genreId] =
|
||||||
|
imageUrlService.getItemImageUrl(
|
||||||
|
itemId = item.id,
|
||||||
|
itemType = item.type,
|
||||||
|
seriesId = null,
|
||||||
|
useSeriesForPrimary = true,
|
||||||
|
imageType = ImageType.BACKDROP,
|
||||||
|
imageTags = item.imageTags.orEmpty(),
|
||||||
|
fillWidth = cardWidthPx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
return genreToUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
@Stable
|
||||||
data class Genre(
|
data class Genre(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val name: String,
|
val name: String,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
val color: Color,
|
|
||||||
) : CardGridItem {
|
) : CardGridItem {
|
||||||
override val gridId: String get() = id.toString()
|
override val gridId: String get() = id.toString()
|
||||||
override val playable: Boolean = false
|
override val playable: Boolean = false
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
package com.github.damontecres.wholphin.ui.main
|
package com.github.damontecres.wholphin.ui.main
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
|
@ -18,11 +16,13 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -34,7 +34,6 @@ import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
|
@ -48,16 +47,19 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.GenreCard
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.FocusableItemRow
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
|
|
@ -71,6 +73,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
|
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
|
||||||
import com.github.damontecres.wholphin.ui.playback.playable
|
import com.github.damontecres.wholphin.ui.playback.playable
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.scale
|
||||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
||||||
|
|
@ -94,12 +97,10 @@ fun HomePage(
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.init()
|
viewModel.init()
|
||||||
}
|
}
|
||||||
val loading by viewModel.loadingState.observeAsState(LoadingState.Loading)
|
val state by viewModel.state.collectAsState()
|
||||||
val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading)
|
val loading = state.loadingState
|
||||||
val watchingRows by viewModel.watchingRows.observeAsState(listOf())
|
val refreshing = state.refreshState
|
||||||
val latestRows by viewModel.latestRows.observeAsState(listOf())
|
val homeRows = state.homeRows
|
||||||
|
|
||||||
val homeRows = remember(watchingRows, latestRows) { watchingRows + latestRows }
|
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
|
|
@ -200,6 +201,9 @@ fun HomePageContent(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onFocusPosition: ((RowColumn) -> Unit)? = null,
|
onFocusPosition: ((RowColumn) -> Unit)? = null,
|
||||||
loadingState: LoadingState? = null,
|
loadingState: LoadingState? = null,
|
||||||
|
listState: LazyListState = rememberLazyListState(),
|
||||||
|
takeFocus: Boolean = true,
|
||||||
|
showEmptyRows: Boolean = false,
|
||||||
) {
|
) {
|
||||||
var position by rememberPosition()
|
var position by rememberPosition()
|
||||||
val focusedItem =
|
val focusedItem =
|
||||||
|
|
@ -207,37 +211,37 @@ fun HomePageContent(
|
||||||
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
|
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
|
||||||
}
|
}
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
|
val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
|
||||||
var firstFocused by remember { mutableStateOf(false) }
|
var firstFocused by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(homeRows) {
|
if (takeFocus) {
|
||||||
if (!firstFocused && homeRows.isNotEmpty()) {
|
LaunchedEffect(homeRows) {
|
||||||
if (position.row >= 0) {
|
if (!firstFocused && homeRows.isNotEmpty()) {
|
||||||
val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex)
|
if (position.row >= 0) {
|
||||||
rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
|
val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex)
|
||||||
firstFocused = true
|
rowFocusRequesters.getOrNull(index)?.tryRequestFocus()
|
||||||
} else {
|
firstFocused = true
|
||||||
// Waiting for the first home row to load, then focus on it
|
} else {
|
||||||
homeRows
|
// Waiting for the first home row to load, then focus on it
|
||||||
.indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
|
homeRows
|
||||||
?.let {
|
.indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
|
||||||
rowFocusRequesters[it].tryRequestFocus()
|
?.let {
|
||||||
firstFocused = true
|
rowFocusRequesters[it].tryRequestFocus()
|
||||||
delay(50)
|
firstFocused = true
|
||||||
listState.scrollToItem(it)
|
delay(50)
|
||||||
}
|
listState.scrollToItem(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(position) {
|
||||||
|
if (position.row >= 0) {
|
||||||
|
listState.animateScrollToItem(position.row)
|
||||||
|
}
|
||||||
|
}
|
||||||
LaunchedEffect(onUpdateBackdrop, focusedItem) {
|
LaunchedEffect(onUpdateBackdrop, focusedItem) {
|
||||||
focusedItem?.let { onUpdateBackdrop.invoke(it) }
|
focusedItem?.let { onUpdateBackdrop.invoke(it) }
|
||||||
}
|
}
|
||||||
val density = LocalDensity.current
|
|
||||||
val spaceAbovePx =
|
|
||||||
with(density) {
|
|
||||||
// The size of the row titles & spacing
|
|
||||||
50.dp.toPx()
|
|
||||||
}
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
HomePageHeader(
|
HomePageHeader(
|
||||||
|
|
@ -247,6 +251,12 @@ fun HomePageContent(
|
||||||
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
|
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
|
||||||
.fillMaxHeight(.33f),
|
.fillMaxHeight(.33f),
|
||||||
)
|
)
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val spaceAbovePx =
|
||||||
|
with(density) {
|
||||||
|
// The size of the row titles & spacing
|
||||||
|
50.dp.toPx()
|
||||||
|
}
|
||||||
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
|
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
|
LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
|
||||||
|
|
@ -263,61 +273,32 @@ fun HomePageContent(
|
||||||
.focusRestorer(),
|
.focusRestorer(),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(homeRows) { rowIndex, row ->
|
itemsIndexed(homeRows) { rowIndex, row ->
|
||||||
CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) {
|
CompositionLocalProvider(
|
||||||
|
LocalBringIntoViewSpec provides defaultBringIntoViewSpec,
|
||||||
|
) {
|
||||||
when (val r = row) {
|
when (val r = row) {
|
||||||
is HomeRowLoadingState.Loading,
|
is HomeRowLoadingState.Loading,
|
||||||
is HomeRowLoadingState.Pending,
|
is HomeRowLoadingState.Pending,
|
||||||
-> {
|
-> {
|
||||||
Column(
|
FocusableItemRow(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
title = r.title,
|
||||||
|
subtitle = stringResource(R.string.loading),
|
||||||
modifier = Modifier.animateItem(),
|
modifier = Modifier.animateItem(),
|
||||||
) {
|
)
|
||||||
Text(
|
|
||||||
text = r.title,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.loading),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowLoadingState.Error -> {
|
is HomeRowLoadingState.Error -> {
|
||||||
var focused by remember { mutableStateOf(false) }
|
FocusableItemRow(
|
||||||
Column(
|
title = r.title,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
subtitle = r.localizedMessage,
|
||||||
modifier =
|
isError = true,
|
||||||
Modifier
|
modifier = Modifier.animateItem(),
|
||||||
.onFocusChanged {
|
)
|
||||||
focused = it.isFocused
|
|
||||||
}.focusable()
|
|
||||||
.background(
|
|
||||||
if (focused) {
|
|
||||||
// Just so the user can tell it has focus
|
|
||||||
MaterialTheme.colorScheme.border.copy(alpha = .25f)
|
|
||||||
} else {
|
|
||||||
Color.Unspecified
|
|
||||||
},
|
|
||||||
).animateItem(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = r.title,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = r.localizedMessage,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is HomeRowLoadingState.Success -> {
|
is HomeRowLoadingState.Success -> {
|
||||||
if (row.items.isNotEmpty()) {
|
if (row.items.isNotEmpty()) {
|
||||||
|
val viewOptions = row.viewOptions
|
||||||
ItemRow(
|
ItemRow(
|
||||||
title = row.title,
|
title = row.title,
|
||||||
items = row.items,
|
items = row.items,
|
||||||
|
|
@ -336,26 +317,20 @@ fun HomePageContent(
|
||||||
.focusGroup()
|
.focusGroup()
|
||||||
.focusRequester(rowFocusRequesters[rowIndex])
|
.focusRequester(rowFocusRequesters[rowIndex])
|
||||||
.animateItem(),
|
.animateItem(),
|
||||||
|
horizontalPadding = viewOptions.spacing.dp,
|
||||||
cardContent = { index, item, cardModifier, onClick, onLongClick ->
|
cardContent = { index, item, cardModifier, onClick, onLongClick ->
|
||||||
BannerCard(
|
HomePageCardContent(
|
||||||
name = item?.data?.seriesName ?: item?.name,
|
index = index,
|
||||||
item = item,
|
item = item,
|
||||||
aspectRatio = AspectRatios.TALL,
|
|
||||||
cornerText = item?.ui?.episodeUnplayedCornerText,
|
|
||||||
played = item?.data?.userData?.played ?: false,
|
|
||||||
favorite = item?.favorite ?: false,
|
|
||||||
playPercent =
|
|
||||||
item?.data?.userData?.playedPercentage
|
|
||||||
?: 0.0,
|
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
viewOptions = viewOptions,
|
||||||
modifier =
|
modifier =
|
||||||
cardModifier
|
cardModifier
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
if (it.isFocused) {
|
if (it.isFocused) {
|
||||||
position =
|
position =
|
||||||
RowColumn(rowIndex, index)
|
RowColumn(rowIndex, index)
|
||||||
// item?.let(onUpdateBackdrop)
|
|
||||||
}
|
}
|
||||||
if (it.isFocused && onFocusPosition != null) {
|
if (it.isFocused && onFocusPosition != null) {
|
||||||
val nonEmptyRowBefore =
|
val nonEmptyRowBefore =
|
||||||
|
|
@ -382,11 +357,15 @@ fun HomePageContent(
|
||||||
}
|
}
|
||||||
return@onKeyEvent false
|
return@onKeyEvent false
|
||||||
},
|
},
|
||||||
interactionSource = null,
|
|
||||||
cardHeight = Cards.height2x3,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
} else if (showEmptyRows) {
|
||||||
|
FocusableItemRow(
|
||||||
|
title = r.title,
|
||||||
|
subtitle = stringResource(R.string.no_results),
|
||||||
|
modifier = Modifier.animateItem(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -427,7 +406,7 @@ fun HomePageHeader(
|
||||||
subtitle = if (isEpisode) dto?.name else null,
|
subtitle = if (isEpisode) dto?.name else null,
|
||||||
overview = dto?.overview,
|
overview = dto?.overview,
|
||||||
overviewTwoLines = isEpisode,
|
overviewTwoLines = isEpisode,
|
||||||
quickDetails = item?.ui?.quickDetails,
|
quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""),
|
||||||
timeRemaining = item?.timeRemainingOrRuntime,
|
timeRemaining = item?.timeRemainingOrRuntime,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
@ -488,3 +467,92 @@ fun HomePageHeader(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomePageCardContent(
|
||||||
|
index: Int,
|
||||||
|
item: BaseItem?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
viewOptions: HomeRowViewOptions,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
when (item?.type) {
|
||||||
|
BaseItemKind.GENRE -> {
|
||||||
|
GenreCard(
|
||||||
|
genreId = item.id,
|
||||||
|
name = item.name,
|
||||||
|
imageUrl = item.imageUrlOverride,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
modifier = modifier.height(viewOptions.heightDp.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val imageType =
|
||||||
|
remember(item, viewOptions) {
|
||||||
|
if (item?.type == BaseItemKind.EPISODE) {
|
||||||
|
viewOptions.episodeImageType.imageType
|
||||||
|
} else {
|
||||||
|
viewOptions.imageType.imageType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val ratio =
|
||||||
|
remember(item, viewOptions) {
|
||||||
|
if (item?.type == BaseItemKind.EPISODE) {
|
||||||
|
viewOptions.episodeAspectRatio.ratio
|
||||||
|
} else {
|
||||||
|
viewOptions.aspectRatio.ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val scale =
|
||||||
|
remember(item, viewOptions) {
|
||||||
|
if (item?.type == BaseItemKind.EPISODE) {
|
||||||
|
viewOptions.episodeContentScale.scale
|
||||||
|
} else {
|
||||||
|
viewOptions.contentScale.scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (viewOptions.showTitles) {
|
||||||
|
BannerCardWithTitle(
|
||||||
|
title = item?.title,
|
||||||
|
subtitle = item?.subtitle,
|
||||||
|
item = item,
|
||||||
|
aspectRatio = ratio,
|
||||||
|
imageType = imageType,
|
||||||
|
imageContentScale = scale,
|
||||||
|
cornerText = item?.ui?.episodeUnplayedCornerText,
|
||||||
|
played = item?.data?.userData?.played ?: false,
|
||||||
|
favorite = item?.favorite ?: false,
|
||||||
|
playPercent =
|
||||||
|
item?.data?.userData?.playedPercentage
|
||||||
|
?: 0.0,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
modifier = modifier,
|
||||||
|
cardHeight = viewOptions.heightDp.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BannerCard(
|
||||||
|
name = item?.data?.seriesName ?: item?.name,
|
||||||
|
item = item,
|
||||||
|
aspectRatio = ratio,
|
||||||
|
imageType = imageType,
|
||||||
|
imageContentScale = scale,
|
||||||
|
cornerText = item?.ui?.episodeUnplayedCornerText,
|
||||||
|
played = item?.data?.userData?.played ?: false,
|
||||||
|
favorite = item?.favorite ?: false,
|
||||||
|
playPercent =
|
||||||
|
item?.data?.userData?.playedPercentage
|
||||||
|
?: 0.0,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
modifier = modifier,
|
||||||
|
interactionSource = null,
|
||||||
|
cardHeight = viewOptions.heightDp.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,40 @@
|
||||||
package com.github.damontecres.wholphin.ui.main
|
package com.github.damontecres.wholphin.ui.main
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.R
|
|
||||||
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
|
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
|
||||||
|
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||||
import com.github.damontecres.wholphin.services.MediaReportService
|
import com.github.damontecres.wholphin.services.MediaReportService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
|
||||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -41,115 +44,120 @@ class HomeViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
val api: ApiClient,
|
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val navDrawerItemRepository: NavDrawerItemRepository,
|
val navDrawerItemRepository: NavDrawerItemRepository,
|
||||||
val mediaReportService: MediaReportService,
|
val mediaReportService: MediaReportService,
|
||||||
|
private val homeSettingsService: HomeSettingsService,
|
||||||
private val favoriteWatchManager: FavoriteWatchManager,
|
private val favoriteWatchManager: FavoriteWatchManager,
|
||||||
private val datePlayedService: DatePlayedService,
|
private val datePlayedService: DatePlayedService,
|
||||||
private val latestNextUpService: LatestNextUpService,
|
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
private val _state = MutableStateFlow(HomeState.EMPTY)
|
||||||
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
val state: StateFlow<HomeState> = _state
|
||||||
val watchingRows = MutableLiveData<List<HomeRowLoadingState>>(listOf())
|
|
||||||
val latestRows = MutableLiveData<List<HomeRowLoadingState>>(listOf())
|
|
||||||
|
|
||||||
private lateinit var preferences: UserPreferences
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
datePlayedService.invalidateAll()
|
datePlayedService.invalidateAll()
|
||||||
init()
|
// init()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init() {
|
fun init() {
|
||||||
viewModelScope.launch(
|
viewModelScope.launchIO {
|
||||||
Dispatchers.IO +
|
|
||||||
LoadingExceptionHandler(
|
|
||||||
loadingState,
|
|
||||||
"Error loading home page",
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Timber.d("init HomeViewModel")
|
Timber.d("init HomeViewModel")
|
||||||
val reload = loadingState.value != LoadingState.Success
|
|
||||||
if (reload) {
|
|
||||||
loadingState.setValueOnMain(LoadingState.Loading)
|
|
||||||
}
|
|
||||||
refreshState.setValueOnMain(LoadingState.Loading)
|
|
||||||
this@HomeViewModel.preferences = userPreferencesService.getCurrent()
|
|
||||||
val prefs = preferences.appPreferences.homePagePreferences
|
|
||||||
val limit = prefs.maxItemsPerRow
|
|
||||||
if (reload) {
|
|
||||||
backdropService.clearBackdrop()
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
val preferences = userPreferencesService.getCurrent()
|
||||||
|
val prefs = preferences.appPreferences.homePagePreferences
|
||||||
|
|
||||||
|
val navDrawerItems =
|
||||||
|
navDrawerItemRepository
|
||||||
|
.getNavDrawerItems()
|
||||||
|
val libraries =
|
||||||
|
navDrawerItems
|
||||||
|
.filter { it is ServerNavDrawerItem }
|
||||||
|
.map {
|
||||||
|
it as ServerNavDrawerItem
|
||||||
|
Library(it.itemId, it.name, it.type)
|
||||||
|
}
|
||||||
serverRepository.currentUserDto.value?.let { userDto ->
|
serverRepository.currentUserDto.value?.let { userDto ->
|
||||||
val includedIds =
|
val settings =
|
||||||
navDrawerItemRepository
|
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
|
||||||
.getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
|
val state = state.value
|
||||||
.filter { it is ServerNavDrawerItem }
|
|
||||||
.map { (it as ServerNavDrawerItem).itemId }
|
// Refreshing if a load has already occurred and the rows haven't significantly changed
|
||||||
val resume = latestNextUpService.getResume(userDto.id, limit, true)
|
val refresh =
|
||||||
val nextUp =
|
state.loadingState == LoadingState.Success && state.settings == settings
|
||||||
latestNextUpService.getNextUp(
|
|
||||||
userDto.id,
|
val semaphore = Semaphore(4)
|
||||||
limit,
|
|
||||||
prefs.enableRewatchingNextUp,
|
val watchingRowIndexes =
|
||||||
false,
|
settings.rows
|
||||||
prefs.maxDaysNextUp,
|
.mapIndexedNotNull { index, row ->
|
||||||
)
|
if (isWatchingRow(row.config)) index else null
|
||||||
val watching =
|
}
|
||||||
buildList {
|
val deferred =
|
||||||
if (prefs.combineContinueNext) {
|
settings.rows
|
||||||
val items = latestNextUpService.buildCombined(resume, nextUp)
|
// Load the watching rows first
|
||||||
add(
|
.sortedByDescending { isWatchingRow(it.config) }
|
||||||
HomeRowLoadingState.Success(
|
.map { row ->
|
||||||
title = context.getString(R.string.continue_watching),
|
viewModelScope.async(Dispatchers.IO) {
|
||||||
items = items,
|
semaphore.withPermit {
|
||||||
),
|
Timber.v("Fetching row: %s", row)
|
||||||
)
|
try {
|
||||||
} else {
|
homeSettingsService.fetchDataForRow(
|
||||||
if (resume.isNotEmpty()) {
|
row = row.config,
|
||||||
add(
|
scope = viewModelScope,
|
||||||
HomeRowLoadingState.Success(
|
prefs = prefs,
|
||||||
title = context.getString(R.string.continue_watching),
|
userDto = userDto,
|
||||||
items = resume,
|
libraries = libraries,
|
||||||
),
|
limit = prefs.maxItemsPerRow,
|
||||||
)
|
)
|
||||||
}
|
} catch (ex: Exception) {
|
||||||
if (nextUp.isNotEmpty()) {
|
Timber.e(ex, "Error on row %s", row)
|
||||||
add(
|
HomeRowLoadingState.Error(row.title, exception = ex)
|
||||||
HomeRowLoadingState.Success(
|
}
|
||||||
title = context.getString(R.string.next_up),
|
}
|
||||||
items = nextUp,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val latest = latestNextUpService.getLatest(userDto, limit, includedIds)
|
if (refresh && state.homeRows.isNotEmpty() && watchingRowIndexes.isNotEmpty()) {
|
||||||
val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) }
|
// Replace watching rows first
|
||||||
|
Timber.v("Refreshing rows: %s", watchingRowIndexes)
|
||||||
withContext(Dispatchers.Main) {
|
val rows =
|
||||||
this@HomeViewModel.watchingRows.value = watching
|
deferred
|
||||||
if (reload) {
|
.filterIndexed { index, _ -> index in watchingRowIndexes }
|
||||||
this@HomeViewModel.latestRows.value = pendingLatest
|
.awaitAll()
|
||||||
|
_state.update {
|
||||||
|
val newRows =
|
||||||
|
it.homeRows.toMutableList().apply {
|
||||||
|
rows.forEachIndexed { index, row ->
|
||||||
|
set(watchingRowIndexes[index], row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
it.copy(
|
||||||
|
loadingState = LoadingState.Success,
|
||||||
|
homeRows = newRows,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
loadingState.value = LoadingState.Success
|
|
||||||
}
|
}
|
||||||
refreshState.setValueOnMain(LoadingState.Success)
|
val rows = deferred.awaitAll()
|
||||||
val loadedLatest = latestNextUpService.loadLatest(latest)
|
Timber.v("Got all rows")
|
||||||
this@HomeViewModel.latestRows.setValueOnMain(loadedLatest)
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
loadingState = LoadingState.Success,
|
||||||
|
refreshState = LoadingState.Success,
|
||||||
|
homeRows = rows,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex)
|
Timber.e(ex, "Exception during home page loading")
|
||||||
if (!reload) {
|
if (state.value.loadingState == LoadingState.Success) {
|
||||||
loadingState.setValueOnMain(LoadingState.Error(ex))
|
|
||||||
} else {
|
|
||||||
showToast(context, "Error refreshing home: ${ex.localizedMessage}")
|
showToast(context, "Error refreshing home: ${ex.localizedMessage}")
|
||||||
|
} else {
|
||||||
|
_state.update {
|
||||||
|
it.copy(loadingState = LoadingState.Error(ex))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -182,16 +190,27 @@ class HomeViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val supportedLatestCollectionTypes =
|
data class HomeState(
|
||||||
setOf(
|
val loadingState: LoadingState,
|
||||||
CollectionType.MOVIES,
|
val refreshState: LoadingState,
|
||||||
CollectionType.TVSHOWS,
|
val homeRows: List<HomeRowLoadingState>,
|
||||||
CollectionType.HOMEVIDEOS,
|
val settings: HomePageResolvedSettings,
|
||||||
// Exclude Live TV because a recording folder view will be used instead
|
) {
|
||||||
null, // Recordings & mixed collection types
|
companion object {
|
||||||
)
|
val EMPTY =
|
||||||
|
HomeState(
|
||||||
|
LoadingState.Pending,
|
||||||
|
LoadingState.Pending,
|
||||||
|
listOf(),
|
||||||
|
HomePageResolvedSettings.EMPTY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class LatestData(
|
/**
|
||||||
val title: String,
|
* Whether a row is a "is watching" type
|
||||||
val request: GetLatestMediaRequest,
|
*/
|
||||||
)
|
private fun isWatchingRow(row: HomeRowConfig) =
|
||||||
|
row is HomeRowConfig.ContinueWatching ||
|
||||||
|
row is HomeRowConfig.NextUp ||
|
||||||
|
row is HomeRowConfig.ContinueWatchingCombined
|
||||||
|
|
|
||||||
|
|
@ -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,
|
val id: Long = 0L,
|
||||||
) : Destination()
|
) : Destination()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object HomeSettings : Destination(true)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Settings(
|
data class Settings(
|
||||||
val screen: PreferenceScreenOption,
|
val screen: PreferenceScreenOption,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
|
||||||
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
|
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
|
||||||
import com.github.damontecres.wholphin.ui.main.HomePage
|
import com.github.damontecres.wholphin.ui.main.HomePage
|
||||||
import com.github.damontecres.wholphin.ui.main.SearchPage
|
import com.github.damontecres.wholphin.ui.main.SearchPage
|
||||||
|
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlaybackPage
|
import com.github.damontecres.wholphin.ui.playback.PlaybackPage
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
|
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
|
||||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
|
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
|
||||||
|
|
@ -63,6 +64,10 @@ fun DestinationContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is Destination.HomeSettings -> {
|
||||||
|
HomeSettingsPage(modifier)
|
||||||
|
}
|
||||||
|
|
||||||
is Destination.PlaybackList,
|
is Destination.PlaybackList,
|
||||||
is Destination.Playback,
|
is Destination.Playback,
|
||||||
-> {
|
-> {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,14 @@ val supportedCollectionTypes =
|
||||||
null, // Mixed
|
null, // Mixed
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val supportedHomeCollectionTypes =
|
||||||
|
setOf(
|
||||||
|
CollectionType.MOVIES,
|
||||||
|
CollectionType.TVSHOWS,
|
||||||
|
CollectionType.HOMEVIDEOS,
|
||||||
|
null, // Mixed
|
||||||
|
)
|
||||||
|
|
||||||
val supportedPlayableTypes =
|
val supportedPlayableTypes =
|
||||||
setOf(
|
setOf(
|
||||||
BaseItemKind.MOVIE,
|
BaseItemKind.MOVIE,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.github.damontecres.wholphin.util
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic state for loading something from the API
|
* Generic state for loading something from the API
|
||||||
|
|
@ -62,6 +63,7 @@ sealed interface HomeRowLoadingState {
|
||||||
data class Success(
|
data class Success(
|
||||||
override val title: String,
|
override val title: String,
|
||||||
val items: List<BaseItem?>,
|
val items: List<BaseItem?>,
|
||||||
|
val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||||
) : HomeRowLoadingState
|
) : HomeRowLoadingState
|
||||||
|
|
||||||
data class Error(
|
data class Error(
|
||||||
|
|
|
||||||
|
|
@ -51,4 +51,6 @@
|
||||||
<string name="fa_clock" translatable="false"></string>
|
<string name="fa_clock" translatable="false"></string>
|
||||||
<string name="fa_bell" translatable="false"></string>
|
<string name="fa_bell" translatable="false"></string>
|
||||||
<string name="fa_check" 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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -498,6 +498,29 @@
|
||||||
<string name="no_limit">No limit</string>
|
<string name="no_limit">No limit</string>
|
||||||
<string name="max_days_next_up">Max days in Next Up</string>
|
<string name="max_days_next_up">Max days in Next Up</string>
|
||||||
|
|
||||||
|
<string name="add_row">Add row</string>
|
||||||
|
<string name="genres_in">Genres in %1$s</string>
|
||||||
|
<string name="recently_released_in">Recently released in %1$s</string>
|
||||||
|
<string name="height">Height</string>
|
||||||
|
<string name="apply_all_rows">Apply to all rows</string>
|
||||||
|
<string name="customize_home">Customize home page</string>
|
||||||
|
<string name="home_rows">Home rows</string>
|
||||||
|
<string name="load_from_server">Load from server</string>
|
||||||
|
<string name="save_to_server">Save to server</string>
|
||||||
|
<string name="load_from_web_client">Load from web client</string>
|
||||||
|
<string name="use_series">Use series image</string>
|
||||||
|
<string name="add_row_for">Add row for %1$s</string>
|
||||||
|
<string name="overwrite_server_settings">Overwrite settings on server?</string>
|
||||||
|
<string name="overwrite_local_settings">Overwrite local settings?</string>
|
||||||
|
<string name="for_episodes">For episodes</string>
|
||||||
|
<string name="suggestions_for">Suggestions for %1$s</string>
|
||||||
|
<string name="increase_all_cards_size">Increase size for all cards</string>
|
||||||
|
<string name="decrease_all_cards_size">Decrease size for all cards</string>
|
||||||
|
<string name="use_thumb_images">Use thumb images</string>
|
||||||
|
<string name="settings_saved">Settings saved</string>
|
||||||
|
<string name="display_presets">Display presets</string>
|
||||||
|
<string name="display_presets_description">Built-in presets to quickly style all rows</string>
|
||||||
|
|
||||||
<string-array name="theme_song_volume">
|
<string-array name="theme_song_volume">
|
||||||
<item>Disabled</item>
|
<item>Disabled</item>
|
||||||
<item>Lowest</item>
|
<item>Lowest</item>
|
||||||
|
|
|
||||||
|
|
@ -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