From 78e7eb421f78d3b11beabbaedb9f4cd15ce11eb3 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sun, 23 Nov 2025 10:40:18 -0500 Subject: [PATCH] Add ability to filter libraries, collection, etc (#308) Adds a button to filter contents next to the sort button on grid pages The filters are saved and restored you return to the page. It works with #278, playing the filtered items in the sorted order. The drop down menus for both sorting & filtering are updated to closer match the overall look. Filters for: - Played or unplayed - Favorite or not - Genres - Community (star) rating - Parental rating - Video type (HD vs SD, etc) - Release year and/or decade --- .../8.json | 315 ++++++++++++++ .../damontecres/wholphin/data/AppDatabase.kt | 18 +- .../wholphin/data/filter/FilterValueOption.kt | 6 + .../wholphin/data/filter/ItemFilterBy.kt | 169 ++++++++ .../wholphin/data/model/GetItemsFilter.kt | 90 +++- .../wholphin/data/model/LibraryDisplayInfo.kt | 12 +- .../ui/components/CollectionFolderGrid.kt | 294 +++++++++++--- .../wholphin/ui/components/FilterByButton.kt | 384 ++++++++++++++++++ .../wholphin/ui/components/Rating.kt | 26 +- .../wholphin/ui/components/SortByButton.kt | 7 +- .../ui/components/TvDropdownMenuItem.kt | 60 +++ .../ui/detail/CollectionFolderGeneric.kt | 4 + .../wholphin/ui/detail/FavoritesPage.kt | 7 + .../wholphin/ui/nav/DestinationContent.kt | 2 + app/src/main/res/values/fa_strings.xml | 1 + app/src/main/res/values/strings.xml | 5 + 16 files changed, 1325 insertions(+), 75 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/8.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/filter/FilterValueOption.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/TvDropdownMenuItem.kt diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/8.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/8.json new file mode 100644 index 00000000..e393f9bd --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/8.json @@ -0,0 +1,315 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "d4874262e325932fe00de49471288d82", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd4874262e325932fe00de49471288d82')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index 2292d7ea..272ff86d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -7,25 +7,29 @@ import androidx.room.TypeConverter import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem +import kotlinx.serialization.json.Json import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.serializer.toUUID +import timber.log.Timber import java.util.UUID @Database( entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class], - version = 7, + version = 8, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), AutoMigration(4, 5), AutoMigration(5, 6), AutoMigration(6, 7), + AutoMigration(7, 8), ], ) @TypeConverters(Converters::class) @@ -57,6 +61,18 @@ class Converters { @TypeConverter fun convertSortOrder(sort: String): SortOrder? = SortOrder.fromNameOrNull(sort) + + @TypeConverter + fun convertGetItemsFilter(filter: GetItemsFilter): String = Json.encodeToString(filter) + + @TypeConverter + fun convertGetItemsFilter(filter: String): GetItemsFilter = + try { + Json.decodeFromString(filter) + } catch (ex: Exception) { + Timber.e(ex, "Error parsing filter") + GetItemsFilter() + } } object Migrations { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/FilterValueOption.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/FilterValueOption.kt new file mode 100644 index 00000000..ae8559b5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/FilterValueOption.kt @@ -0,0 +1,6 @@ +package com.github.damontecres.wholphin.data.filter + +data class FilterValueOption( + val name: String, + val value: Any?, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt new file mode 100644 index 00000000..92dbecf3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt @@ -0,0 +1,169 @@ +package com.github.damontecres.wholphin.data.filter + +import androidx.annotation.StringRes +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import java.util.UUID + +val DefaultFilterOptions = + listOf( + PlayedFilter, + FavoriteFilter, + GenreFilter, + CommunityRatingFilter, + OfficialRatingFilter, + VideoTypeFilter, + YearFilter, + DecadeFilter, + ) + +val DefaultForFavoritesFilterOptions = + listOf( + PlayedFilter, + GenreFilter, + CommunityRatingFilter, + OfficialRatingFilter, + VideoTypeFilter, + YearFilter, + DecadeFilter, + ) + +val DefaultForGenresFilterOptions = + listOf( + PlayedFilter, + FavoriteFilter, + CommunityRatingFilter, + OfficialRatingFilter, + VideoTypeFilter, + YearFilter, + DecadeFilter, + ) + +sealed interface ItemFilterBy { + @get:StringRes + val stringRes: Int + + val supportMultiple: Boolean + + fun get(filter: GetItemsFilter): T? + + fun set( + value: T?, + filter: GetItemsFilter, + ): GetItemsFilter +} + +data object GenreFilter : ItemFilterBy> { + override val stringRes: Int = R.string.genres + + override val supportMultiple: Boolean = true + + override fun get(filter: GetItemsFilter): List? = filter.genres + + override fun set( + value: List?, + filter: GetItemsFilter, + ): GetItemsFilter = filter.copy(genres = value) +} + +data object PlayedFilter : ItemFilterBy { + override val stringRes: Int = R.string.played + + override val supportMultiple: Boolean = false + + override fun get(filter: GetItemsFilter): Boolean? = filter.played + + override fun set( + value: Boolean?, + filter: GetItemsFilter, + ): GetItemsFilter = filter.copy(played = value) +} + +data object FavoriteFilter : ItemFilterBy { + override val stringRes: Int = R.string.favorites + + override val supportMultiple: Boolean = false + + override fun get(filter: GetItemsFilter): Boolean? = filter.favorite + + override fun set( + value: Boolean?, + filter: GetItemsFilter, + ): GetItemsFilter = filter.copy(favorite = value) +} + +data object OfficialRatingFilter : ItemFilterBy> { + override val stringRes: Int = R.string.official_rating + + override val supportMultiple: Boolean = true + + override fun get(filter: GetItemsFilter): List? = filter.officialRatings + + override fun set( + value: List?, + filter: GetItemsFilter, + ): GetItemsFilter = filter.copy(officialRatings = value) +} + +enum class FilterVideoType( + val readable: String, +) { + FOUR_K("4K"), + HD("HD"), + SD("SD"), + THREE_D("3D"), + BLU_RAY("Blu-Ray"), + DVD("DVD"), +} + +data object VideoTypeFilter : ItemFilterBy> { + override val stringRes: Int = R.string.video + + override val supportMultiple: Boolean = true + + override fun get(filter: GetItemsFilter): List? = filter.videoTypes + + override fun set( + value: List?, + filter: GetItemsFilter, + ): GetItemsFilter = filter.copy(videoTypes = value) +} + +data object YearFilter : ItemFilterBy> { + override val stringRes: Int = R.string.year + + override val supportMultiple: Boolean = true + + override fun get(filter: GetItemsFilter): List? = filter.years + + override fun set( + value: List?, + filter: GetItemsFilter, + ): GetItemsFilter = filter.copy(years = value) +} + +data object DecadeFilter : ItemFilterBy> { + override val stringRes: Int = R.string.decade + + override val supportMultiple: Boolean = true + + override fun get(filter: GetItemsFilter): List? = filter.decades + + override fun set( + value: List?, + filter: GetItemsFilter, + ): GetItemsFilter = filter.copy(decades = value) +} + +data object CommunityRatingFilter : ItemFilterBy { + override val stringRes: Int = R.string.community_rating + + override val supportMultiple: Boolean = true + + override fun get(filter: GetItemsFilter): Int? = filter.minCommunityRating?.toInt() + + override fun set( + value: Int?, + filter: GetItemsFilter, + ): GetItemsFilter = filter.copy(minCommunityRating = value?.toDouble()) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt index 6330113e..b7d01423 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt @@ -2,9 +2,13 @@ package com.github.damontecres.wholphin.data.model +import com.github.damontecres.wholphin.data.filter.FilterVideoType +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.ui.letNotEmpty import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.VideoType import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.serializer.UUIDSerializer @@ -14,30 +18,112 @@ import java.util.UUID data class GetItemsFilter( val favorite: Boolean? = null, val genres: List? = null, - val minCriticRating: Double? = null, + val minCommunityRating: Double? = null, + val officialRatings: List? = null, val persons: List? = null, val played: Boolean? = null, val studios: List? = null, val tags: List? = null, val includeItemTypes: List? = null, + val videoTypes: List? = null, + val years: List? = null, + val decades: List? = null, val override: GetItemsFilterOverride = GetItemsFilterOverride.NONE, ) { + /** + * Returns how many of filters are actually being used in this [GetItemsFilter] + */ + fun countFilters(filterOptions: List>): Int { + var count = 0 + filterOptions.forEach { + if (it.get(this) != null) count++ + } + return count + } + + /** + * Clear all of the values for the given filters + */ + fun delete(filterOptions: List>): GetItemsFilter { + var newFilter = this + filterOptions.forEach { + newFilter = it.set(null, newFilter) + } + return newFilter + } + fun applyTo(req: GetItemsRequest) = req.copy( includeItemTypes = includeItemTypes, isFavorite = favorite, genreIds = genres, - minCriticRating = minCriticRating, + minCommunityRating = minCommunityRating, personIds = persons, isPlayed = played, studioIds = studios, tags = tags, + officialRatings = officialRatings, + years = + buildSet { + years?.letNotEmpty(::addAll) + decades?.forEach { addAll(it..<(it + 10)) } + }, + is4k = + videoTypes?.letNotEmpty { + videoTypes.contains(FilterVideoType.FOUR_K).takeIf { it } + }, + isHd = + videoTypes?.letNotEmpty { + if (videoTypes.contains(FilterVideoType.HD)) { + true + } else if (videoTypes.contains(FilterVideoType.SD)) { + false + } else { + null + } + }, + is3d = + videoTypes?.letNotEmpty { + videoTypes.contains(FilterVideoType.THREE_D).takeIf { it } + }, + videoTypes = + videoTypes?.letNotEmpty { + it.mapNotNull { + when (it) { + FilterVideoType.FOUR_K, + FilterVideoType.HD, + FilterVideoType.SD, + FilterVideoType.THREE_D, + -> null + + FilterVideoType.BLU_RAY -> VideoType.BLU_RAY + FilterVideoType.DVD -> VideoType.DVD + } + } + }, ) fun applyTo(req: GetPersonsRequest) = req.copy( isFavorite = favorite, ) + + fun merge(filter: GetItemsFilter): GetItemsFilter = + this.copy( + favorite = favorite ?: filter.favorite, + genres = genres ?: filter.genres, + minCommunityRating = minCommunityRating ?: filter.minCommunityRating, + officialRatings = officialRatings ?: filter.officialRatings, + persons = persons ?: filter.persons, + played = played ?: filter.played, + studios = studios ?: filter.studios, + tags = tags ?: filter.tags, + includeItemTypes = includeItemTypes ?: filter.includeItemTypes, + videoTypes = videoTypes ?: filter.videoTypes, + years = years ?: filter.years, + decades = decades ?: filter.decades, + override = override, + ) } enum class GetItemsFilterOverride { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt index 34390cc3..aaafbbf4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt @@ -2,19 +2,18 @@ package com.github.damontecres.wholphin.data.model +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Ignore import androidx.room.Index import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.toServerString import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.serializer.UUIDSerializer -import java.util.UUID @Entity( foreignKeys = [ @@ -35,14 +34,9 @@ data class LibraryDisplayInfo( val itemId: String, val sort: ItemSortBy, val direction: SortOrder, + @ColumnInfo(defaultValue = "{}") + val filter: GetItemsFilter, ) { - constructor( - userId: Int, - itemId: UUID, - sort: ItemSortBy, - direction: SortOrder, - ) : this(userId, itemId.toServerString(), sort, direction) - @Ignore @Transient val sortAndDirection = SortAndDirection(sort, direction) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 3f0b3ce1..c916fd59 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -6,12 +6,16 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background 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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable @@ -40,6 +44,18 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter +import com.github.damontecres.wholphin.data.filter.DecadeFilter +import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions +import com.github.damontecres.wholphin.data.filter.FavoriteFilter +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.FilterVideoType +import com.github.damontecres.wholphin.data.filter.GenreFilter +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter +import com.github.damontecres.wholphin.data.filter.PlayedFilter +import com.github.damontecres.wholphin.data.filter.VideoTypeFilter +import com.github.damontecres.wholphin.data.filter.YearFilter import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride @@ -72,9 +88,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.genresApi +import org.jellyfin.sdk.api.client.extensions.localizationApi +import org.jellyfin.sdk.api.client.extensions.yearsApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType @@ -84,6 +104,8 @@ import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.util.TreeSet import java.util.UUID import javax.inject.Inject import kotlin.time.Duration @@ -100,6 +122,7 @@ class CollectionFolderViewModel val navigationManager: NavigationManager, ) : ItemViewModel(api) { val loading = MutableLiveData(LoadingState.Loading) + val backgroundLoading = MutableLiveData(LoadingState.Loading) val pager = MutableLiveData>(listOf()) val sortAndDirection = MutableLiveData() val filter = MutableLiveData(GetItemsFilter()) @@ -121,26 +144,62 @@ class CollectionFolderViewModel ) { this@CollectionFolderViewModel.useSeriesForPrimary = useSeriesForPrimary this@CollectionFolderViewModel.itemId = itemId - itemId?.toUUIDOrNull()?.let { + itemId.toUUIDOrNull()?.let { fetchItem(it) } + val libraryDisplayInfo = + serverRepository.currentUser.value?.let { user -> + libraryDisplayInfoDao.getItem(user, itemId) + } + val sortAndDirection = initialSortAndDirection - ?: ( - serverRepository.currentUser.value?.let { user -> - libraryDisplayInfoDao.getItem(user, itemId)?.sortAndDirection - } ?: SortAndDirection.DEFAULT - ) + ?: libraryDisplayInfo?.sortAndDirection + ?: SortAndDirection.DEFAULT - loadResults(sortAndDirection, recursive, filter, useSeriesForPrimary) + val filterToUse = + if (libraryDisplayInfo?.filter != null) { + filter.merge(libraryDisplayInfo.filter) + } else { + filter + } + + loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) } + fun onFilterChange( + newFilter: GetItemsFilter, + recursive: Boolean, + ) { + Timber.v("onFilterChange: filter=%s", newFilter) + serverRepository.currentUser.value?.let { user -> + viewModelScope.launch(Dispatchers.IO) { + val libraryDisplayInfo = + LibraryDisplayInfo( + userId = user.rowId, + itemId = itemId, + sort = sortAndDirection.value!!.sort, + direction = sortAndDirection.value!!.direction, + filter = newFilter, + ) + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } + } + loadResults(false, sortAndDirection.value!!, recursive, newFilter, useSeriesForPrimary) + } + fun onSortChange( sortAndDirection: SortAndDirection, recursive: Boolean, filter: GetItemsFilter, ) { + Timber.v( + "onSortChange: sort=%s, recursive=%s, filter=%s", + sortAndDirection, + recursive, + filter, + ) serverRepository.currentUser.value?.let { user -> viewModelScope.launch(Dispatchers.IO) { val libraryDisplayInfo = @@ -149,14 +208,16 @@ class CollectionFolderViewModel itemId = itemId, sort = sortAndDirection.sort, direction = sortAndDirection.direction, + filter = filter, ) libraryDisplayInfoDao.saveItem(libraryDisplayInfo) } } - loadResults(sortAndDirection, recursive, filter, useSeriesForPrimary) + loadResults(true, sortAndDirection, recursive, filter, useSeriesForPrimary) } private fun loadResults( + resetState: Boolean, sortAndDirection: SortAndDirection, recursive: Boolean, filter: GetItemsFilter, @@ -164,17 +225,22 @@ class CollectionFolderViewModel ) { viewModelScope.launch(Dispatchers.IO) { withContext(Dispatchers.Main) { - pager.value = listOf() - loading.value = LoadingState.Loading + if (resetState) { + pager.value = listOf() + loading.value = LoadingState.Loading + } + backgroundLoading.value = LoadingState.Loading this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection this@CollectionFolderViewModel.filter.value = filter } + delay(1000) val newPager = createPager(sortAndDirection, recursive, filter, useSeriesForPrimary) newPager.init() if (newPager.isNotEmpty()) newPager.getBlocking(0) withContext(Dispatchers.Main) { pager.value = newPager loading.value = LoadingState.Success + backgroundLoading.value = LoadingState.Success } } } @@ -256,6 +322,78 @@ class CollectionFolderViewModel } } + suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = + try { + when (filterOption) { + GenreFilter -> { + api.genresApi + .getGenres( + parentId = itemUuid, + userId = serverRepository.currentUser.value?.id, + ).content.items + .map { FilterValueOption(it.name ?: "", it.id) } + } + + FavoriteFilter, + PlayedFilter, + -> + listOf( + FilterValueOption("True", null), + FilterValueOption("False", null), + ) + + OfficialRatingFilter -> { + api.localizationApi.getParentalRatings().content.map { + FilterValueOption(it.name ?: "", it.value) + } + } + + VideoTypeFilter -> + FilterVideoType.entries.map { + FilterValueOption(it.readable, it) + } + + YearFilter -> { + api.yearsApi + .getYears( + parentId = itemUuid, + userId = serverRepository.currentUser.value?.id, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNull { + it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) } + } + } + + DecadeFilter -> { + val items = TreeSet() + api.yearsApi + .getYears( + parentId = itemUuid, + userId = serverRepository.currentUser.value?.id, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNullTo(items) { + it.name + ?.toIntOrNull() + ?.div(10) + ?.times(10) + } + items.toList().sorted().map { FilterValueOption("$it's", it) } + } + + CommunityRatingFilter -> + (1..10).map { + FilterValueOption("$it", it) + } + } + } catch (ex: Exception) { + Timber.e(ex, "Exception get filter value options for $filterOption") + listOf() + } + suspend fun positionOfLetter(letter: Char): Int? = withContext(Dispatchers.IO) { item.value?.let { item -> @@ -319,6 +457,8 @@ fun CollectionFolderGrid( showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, params: CollectionFolderGridParameters = CollectionFolderGridParameters(), + useSeriesForPrimary: Boolean = true, + filterOptions: List> = DefaultFilterOptions, ) = CollectionFolderGrid( preferences, itemId.toServerString(), @@ -332,6 +472,8 @@ fun CollectionFolderGrid( showTitle = showTitle, positionCallback = positionCallback, params = params, + useSeriesForPrimary = useSeriesForPrimary, + filterOptions = filterOptions, ) @Composable @@ -351,6 +493,7 @@ fun CollectionFolderGrid( positionCallback: ((columns: Int, position: Int) -> Unit)? = null, params: CollectionFolderGridParameters = CollectionFolderGridParameters(), useSeriesForPrimary: Boolean = true, + filterOptions: List> = DefaultFilterOptions, ) { val context = LocalContext.current OneTimeLaunchedEffect { @@ -362,9 +505,10 @@ fun CollectionFolderGrid( useSeriesForPrimary, ) } - val sortAndDirection by viewModel.sortAndDirection.observeAsState() + val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT) val filter by viewModel.filter.observeAsState(initialFilter) val loading by viewModel.loading.observeAsState(LoadingState.Loading) + val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() val pager by viewModel.pager.observeAsState() @@ -379,40 +523,67 @@ fun CollectionFolderGrid( -> LoadingPage() LoadingState.Success -> { pager?.let { pager -> - CollectionFolderGridContent( - preferences, - item, - pager, - sortAndDirection = sortAndDirection!!, - modifier = modifier, - onClickItem = onClickItem, - onLongClickItem = { position, item -> - moreDialog.makePresent(PositionItem(position, item)) - }, - onSortChange = { - viewModel.onSortChange(it, recursive, filter) - }, - showTitle = showTitle, - sortOptions = sortOptions, - positionCallback = positionCallback, - letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, - params = params, - playEnabled = playEnabled, - onClickPlay = { shuffle -> - itemId.toUUIDOrNull()?.let { - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it, - startIndex = 0, - shuffle = shuffle, - recursive = recursive, - sortAndDirection = sortAndDirection, - filter = filter, - ), - ) - } - }, - ) + Box(modifier = modifier) { + CollectionFolderGridContent( + preferences, + item, + pager, + sortAndDirection = sortAndDirection!!, + modifier = Modifier.fillMaxSize(), + onClickItem = onClickItem, + onLongClickItem = { position, item -> + moreDialog.makePresent(PositionItem(position, item)) + }, + onSortChange = { + viewModel.onSortChange(it, recursive, filter) + }, + filterOptions = filterOptions, + currentFilter = filter, + onFilterChange = { + viewModel.onFilterChange(it, recursive) + }, + getPossibleFilterValues = { + viewModel.getFilterOptionValues(it) + }, + showTitle = showTitle, + sortOptions = sortOptions, + positionCallback = positionCallback, + letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, + params = params, + playEnabled = playEnabled, + onClickPlay = { shuffle -> + itemId.toUUIDOrNull()?.let { + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = it, + startIndex = 0, + shuffle = shuffle, + recursive = recursive, + sortAndDirection = sortAndDirection, + filter = filter, + ), + ) + } + }, + ) + + AnimatedVisibility( + backgroundLoading == LoadingState.Loading, + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) { + CircularProgress( + Modifier + .background( + MaterialTheme.colorScheme.background.copy(alpha = .25f), + shape = CircleShape, + ).size(64.dp) + .padding(4.dp), + ) + } + } } } } @@ -484,6 +655,10 @@ fun CollectionFolderGridContent( showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, params: CollectionFolderGridParameters = CollectionFolderGridParameters(), + currentFilter: GetItemsFilter = GetItemsFilter(), + filterOptions: List> = listOf(), + onFilterChange: (GetItemsFilter) -> Unit = {}, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, ) { val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) @@ -525,13 +700,30 @@ fun CollectionFolderGridContent( .padding(start = 16.dp, end = endPadding) .fillMaxWidth(), ) { - if (sortOptions.isNotEmpty()) { - SortByButton( - sortOptions = sortOptions, - current = sortAndDirection, - onSortChange = onSortChange, + if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, modifier = Modifier, - ) + ) { + if (sortOptions.isNotEmpty()) { + SortByButton( + sortOptions = sortOptions, + current = sortAndDirection, + onSortChange = onSortChange, + modifier = Modifier, + ) + } + if (filterOptions.isNotEmpty()) { + FilterByButton( + filterOptions = filterOptions, + current = currentFilter, + onFilterChange = onFilterChange, + getPossibleValues = getPossibleFilterValues, + modifier = Modifier, + ) + } + } } if (playEnabled) { Row( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt new file mode 100644 index 00000000..662706ed --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt @@ -0,0 +1,384 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Button +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter +import com.github.damontecres.wholphin.data.filter.DecadeFilter +import com.github.damontecres.wholphin.data.filter.FavoriteFilter +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.FilterVideoType +import com.github.damontecres.wholphin.data.filter.GenreFilter +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter +import com.github.damontecres.wholphin.data.filter.PlayedFilter +import com.github.damontecres.wholphin.data.filter.VideoTypeFilter +import com.github.damontecres.wholphin.data.filter.YearFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.ui.DefaultButtonPadding +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.tryRequestFocus +import java.util.UUID + +@Composable +fun FilterByButton( + filterOptions: List>, + current: GetItemsFilter, + onFilterChange: (GetItemsFilter) -> Unit, + getPossibleValues: suspend (ItemFilterBy<*>) -> List, + modifier: Modifier = Modifier, +) { + var dropDown by remember { mutableStateOf(false) } + var nestedDropDown by remember { mutableStateOf?>(null) } + val filterCount = remember(current, filterOptions) { current.countFilters(filterOptions) } + + Box(modifier = modifier) { + ExpandableFilterButton( + filterCount = filterCount, + onClick = { dropDown = true }, + modifier = Modifier, + ) + + DropdownMenu( + expanded = dropDown, + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + onDismissRequest = { + dropDown = false + nestedDropDown = null + }, + ) { + filterOptions + .forEach { filterOption -> + val currentValue = remember(current) { filterOption.get(current) } + TvDropdownMenuItem( + leadingIcon = { + if (currentValue != null) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Filter active", + ) + } + }, + text = { + Text( + text = stringResource(filterOption.stringRes), + ) + }, + onClick = { + nestedDropDown = filterOption + }, + modifier = Modifier, + ) + } + if (filterCount > 0) { + HorizontalDivider() + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + TvDropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = Color.Red.copy(alpha = .8f), + ) + }, + text = { + Text( + text = stringResource(R.string.remove), + color = if (focused) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.error, + ) + }, + onClick = { + onFilterChange.invoke(current.delete(filterOptions)) + dropDown = false + }, + interactionSource = interactionSource, + modifier = Modifier, + ) + } + } + + DropdownMenu( + expanded = nestedDropDown != null, + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp), + offset = DpOffset(80.dp, 16.dp), + onDismissRequest = { + nestedDropDown = null + }, + ) { + nestedDropDown?.let { filterOption -> + filterOption as ItemFilterBy + val currentValue = remember(current) { filterOption.get(current) } + + var possibleValues by remember { mutableStateOf?>(null) } + LaunchedEffect(Unit) { + possibleValues = getPossibleValues.invoke(filterOption) + } + + if (currentValue != null) { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + TvDropdownMenuItem( + elevation = 5.dp, + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = Color.Red.copy(alpha = .8f), + ) + }, + text = { + Text( + text = stringResource(R.string.remove), + color = if (focused) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.error, + ) + }, + onClick = { + onFilterChange.invoke(filterOption.set(null, current)) + nestedDropDown = null + }, + interactionSource = interactionSource, + modifier = Modifier, + ) + HorizontalDivider() + } + + if (possibleValues == null) { + CircularProgress(Modifier.size(48.dp)) + } else if (possibleValues?.isEmpty() == true) { + Text( + text = stringResource(R.string.no_results), + ) + } else { + possibleValues.orEmpty().forEachIndexed { index, value -> + val focusRequester = remember { FocusRequester() } + if (index == 0) { + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + } + + val isSelected = + remember(currentValue) { + when (filterOption) { + GenreFilter -> { + (currentValue as? List) + .orEmpty() + .contains(value.value) + } + + FavoriteFilter, + PlayedFilter, + -> (currentValue as? Boolean) == value.name.toBoolean() + + OfficialRatingFilter -> { + (currentValue as? List) + .orEmpty() + .contains(value.name) + } + + VideoTypeFilter -> + (currentValue as? List) + .orEmpty() + .contains(value.value) + + YearFilter, + DecadeFilter, + -> + (currentValue as? List) + .orEmpty() + .contains(value.value) + + CommunityRatingFilter -> (currentValue as? Int) == value.value + } + } + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + TvDropdownMenuItem( + elevation = 8.dp, + interactionSource = interactionSource, + leadingIcon = { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Filter active", + ) + } + }, + text = { + if (filterOption == CommunityRatingFilter) { + SimpleStarRating( + "${value.name}+", + starColor = if (focused) EmptyStarColor else FilledStarColor, + ) + } else { + Text( + text = value.name, + ) + } + }, + onClick = { + val newFilter = + when (filterOption) { + GenreFilter -> { + val list = (currentValue as? List).orEmpty() + val newValue = + list + .toMutableList() + .apply { + if (isSelected) { + remove(value.value!!) + } else { + add(value.value!! as UUID) + } + }.takeIf { it.isNotEmpty() } + filterOption.set(newValue, current) + } + + FavoriteFilter, + PlayedFilter, + -> { + val played = value.name.toBoolean() + filterOption.set(played, current) + } + + OfficialRatingFilter -> { + val list = (currentValue as? List).orEmpty() + val newValue = + list + .toMutableList() + .apply { + if (isSelected) { + remove(value.name) + } else { + add(value.name) + } + }.takeIf { it.isNotEmpty() } + filterOption.set(newValue, current) + } + + VideoTypeFilter -> { + val list = + (currentValue as? List).orEmpty() + val newValue = + list + .toMutableList() + .apply { + if (isSelected) { + remove(value.value) + } else { + add(value.value as FilterVideoType) + } + }.takeIf { it.isNotEmpty() } + filterOption.set(newValue, current) + } + + YearFilter, + DecadeFilter, + -> { + val list = + (currentValue as? List).orEmpty() + val newValue = + list + .toMutableList() + .apply { + if (isSelected) { + remove(value.value) + } else { + add(value.value as Int) + } + }.takeIf { it.isNotEmpty() } + filterOption.set(newValue, current) + } + + CommunityRatingFilter -> + filterOption.set( + value.value as? Int, + current, + ) + } + + onFilterChange.invoke(newFilter) + if (!filterOption.supportMultiple) { + nestedDropDown = null + } + }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + } + } + } + } +} + +@Composable +fun ExpandableFilterButton( + filterCount: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + iconColor: Color = Color.Unspecified, +) { + val resources = LocalResources.current + val isFocused = interactionSource.collectIsFocusedAsState().value + Button( + onClick = onClick, + modifier = modifier.heightIn(min = 40.dp), + contentPadding = DefaultButtonPadding, + interactionSource = interactionSource, + ) { + Text( + text = stringResource(R.string.fa_filter), + style = MaterialTheme.typography.titleSmall, + color = iconColor, + fontSize = 16.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + modifier = Modifier, + ) + AnimatedVisibility(filterCount > 0) { + Text( + text = filterCount.toString(), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(start = 4.dp), + ) + } + AnimatedVisibility(isFocused) { + Text( + text = stringResource(R.string.filter), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(start = 4.dp), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt index 4a453cce..d719344a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt @@ -38,10 +38,12 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon +import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.theme.WholphinTheme @@ -67,7 +69,7 @@ enum class StarRatingPrecision { } val FilledStarColor = Color(0xFFFFC700) -val EmptyStarColor = Color(0x2AFFC700) +val EmptyStarColor = Color(0xFF5D4A0A) val ratingBarHeight: Dp = 32.dp @@ -75,22 +77,34 @@ val ratingBarHeight: Dp = 32.dp fun SimpleStarRating( communityRating: Float?, modifier: Modifier = Modifier, +) = SimpleStarRating( + text = communityRating?.let { String.format(Locale.getDefault(), "%.1f", it) }, + modifier = modifier, + textColor = MaterialTheme.colorScheme.onSurface, +) + +@Composable +fun SimpleStarRating( + text: String?, + modifier: Modifier = Modifier, + textColor: Color = LocalContentColor.current, + starColor: Color = FilledStarColor, ) { Row( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, modifier = modifier, ) { - communityRating?.let { + if (text.isNotNullOrBlank()) { Text( - text = String.format(Locale.getDefault(), "%.1f", it), + text = text, fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurface, + color = textColor, modifier = Modifier, ) Icon( imageVector = Icons.Filled.Star, - tint = FilledStarColor, + tint = starColor, contentDescription = null, modifier = Modifier, ) @@ -254,7 +268,7 @@ private fun SimpleStarRatingPreview() { Column { SimpleStarRating(7.5f, Modifier.height(32.dp)) SimpleStarRating(7.5f, Modifier.height(20.dp)) - SimpleStarRating(null, Modifier.height(32.dp)) + SimpleStarRating("", Modifier.height(32.dp)) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt index 81b9aea0..d63842c1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SortByButton.kt @@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.ui.components import androidx.compose.foundation.layout.Box import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -15,7 +14,6 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.tv.material3.Button -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.FontAwesome @@ -77,19 +75,17 @@ fun SortByButton( sortOptions // .sortedBy { it.name } .forEach { sortOption -> - DropdownMenuItem( + TvDropdownMenuItem( leadingIcon = { if (sortOption == currentSort) { if (currentDirection == SortOrder.ASCENDING) { Text( text = stringResource(R.string.fa_caret_up), - color = MaterialTheme.colorScheme.onSecondaryContainer, fontFamily = FontAwesome, ) } else { Text( text = stringResource(R.string.fa_caret_down), - color = MaterialTheme.colorScheme.onSecondaryContainer, fontFamily = FontAwesome, ) } @@ -98,7 +94,6 @@ fun SortByButton( text = { Text( text = stringResource(getStringRes(sortOption)), - color = MaterialTheme.colorScheme.onSecondaryContainer, ) }, onClick = { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TvDropdownMenuItem.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TvDropdownMenuItem.kt new file mode 100644 index 00000000..144eb8d3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TvDropdownMenuItem.kt @@ -0,0 +1,60 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MenuDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.LocalContentColor +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.surfaceColorAtElevation + +@Composable +fun TvDropdownMenuItem( + text: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + enabled: Boolean = true, + contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + elevation: Dp = 3.dp, +) { + val focused by interactionSource.collectIsFocusedAsState() + val backgroundColor = + if (focused) { + MaterialTheme.colorScheme.inverseSurface + } else { + MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) + } + val contentColor = + if (focused) MaterialTheme.colorScheme.inverseOnSurface else MaterialTheme.colorScheme.onSurface + CompositionLocalProvider(LocalContentColor provides contentColor) { + DropdownMenuItem( + enabled = enabled, + colors = + MenuDefaults.itemColors( + textColor = contentColor, + leadingIconColor = contentColor, + trailingIconColor = contentColor, + ), + leadingIcon = leadingIcon, + text = text, + trailingIcon = trailingIcon, + onClick = onClick, + interactionSource = interactionSource, + contentPadding = contentPadding, + modifier = + modifier.background(backgroundColor), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt index f4246e27..26a669fe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt @@ -9,6 +9,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions +import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid @@ -26,6 +28,7 @@ fun CollectionFolderGeneric( playEnabled: Boolean, modifier: Modifier = Modifier, filter: GetItemsFilter = GetItemsFilter(), + filterOptions: List> = DefaultFilterOptions, preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { var showHeader by remember { mutableStateOf(true) } @@ -53,5 +56,6 @@ fun CollectionFolderGeneric( }, params = params, playEnabled = playEnabled, + filterOptions = filterOptions, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index 7167fab8..a68ce42d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.filter.DefaultForFavoritesFilterOptions import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride @@ -129,6 +130,7 @@ fun FavoritesPage( showHeader = position < columns }, playEnabled = false, + filterOptions = DefaultForFavoritesFilterOptions, ) } // TV @@ -154,6 +156,7 @@ fun FavoritesPage( showHeader = position < columns }, playEnabled = false, + filterOptions = DefaultForFavoritesFilterOptions, ) } // Episodes @@ -181,6 +184,7 @@ fun FavoritesPage( showHeader = position < columns }, playEnabled = false, + filterOptions = DefaultForFavoritesFilterOptions, ) } // Videos @@ -207,6 +211,7 @@ fun FavoritesPage( showHeader = position < columns }, playEnabled = false, + filterOptions = DefaultForFavoritesFilterOptions, ) } // Playlists @@ -233,6 +238,7 @@ fun FavoritesPage( showHeader = position < columns }, playEnabled = false, + filterOptions = DefaultForFavoritesFilterOptions, ) } // People @@ -263,6 +269,7 @@ fun FavoritesPage( showHeader = position < columns }, playEnabled = false, + filterOptions = listOf(), ) } else -> ErrorMessage("Invalid tab index $selectedTabIndex", null) 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 f416851c..dedb3ad0 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 @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.nav import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.tv.material3.Text +import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.LicenseInfo @@ -166,6 +167,7 @@ fun DestinationContent( recursive = destination.recursive, usePosters = true, playEnabled = true, // TODO only genres use this currently, so might need to change in future + filterOptions = DefaultForGenresFilterOptions, modifier = modifier, ) diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 70099f95..c91db2c9 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -38,4 +38,5 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5286503e..5c9f6f65 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -319,6 +319,11 @@ Skip Recap Skip Outro Skip Intro + Played + Filter + Year + Decade + Remove Disabled