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