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
This commit is contained in:
damontecres 2025-11-23 10:40:18 -05:00 committed by GitHub
parent 50dc4b9a35
commit 78e7eb421f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1325 additions and 75 deletions

View file

@ -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')"
]
}
}

View file

@ -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 {

View file

@ -0,0 +1,6 @@
package com.github.damontecres.wholphin.data.filter
data class FilterValueOption(
val name: String,
val value: Any?,
)

View file

@ -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<T> {
@get:StringRes
val stringRes: Int
val supportMultiple: Boolean
fun get(filter: GetItemsFilter): T?
fun set(
value: T?,
filter: GetItemsFilter,
): GetItemsFilter
}
data object GenreFilter : ItemFilterBy<List<UUID>> {
override val stringRes: Int = R.string.genres
override val supportMultiple: Boolean = true
override fun get(filter: GetItemsFilter): List<UUID>? = filter.genres
override fun set(
value: List<UUID>?,
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(genres = value)
}
data object PlayedFilter : ItemFilterBy<Boolean> {
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<Boolean> {
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<List<String>> {
override val stringRes: Int = R.string.official_rating
override val supportMultiple: Boolean = true
override fun get(filter: GetItemsFilter): List<String>? = filter.officialRatings
override fun set(
value: List<String>?,
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<List<FilterVideoType>> {
override val stringRes: Int = R.string.video
override val supportMultiple: Boolean = true
override fun get(filter: GetItemsFilter): List<FilterVideoType>? = filter.videoTypes
override fun set(
value: List<FilterVideoType>?,
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(videoTypes = value)
}
data object YearFilter : ItemFilterBy<List<Int>> {
override val stringRes: Int = R.string.year
override val supportMultiple: Boolean = true
override fun get(filter: GetItemsFilter): List<Int>? = filter.years
override fun set(
value: List<Int>?,
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(years = value)
}
data object DecadeFilter : ItemFilterBy<List<Int>> {
override val stringRes: Int = R.string.decade
override val supportMultiple: Boolean = true
override fun get(filter: GetItemsFilter): List<Int>? = filter.decades
override fun set(
value: List<Int>?,
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(decades = value)
}
data object CommunityRatingFilter : ItemFilterBy<Int> {
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())
}

View file

@ -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<UUID>? = null,
val minCriticRating: Double? = null,
val minCommunityRating: Double? = null,
val officialRatings: List<String>? = null,
val persons: List<UUID>? = null,
val played: Boolean? = null,
val studios: List<UUID>? = null,
val tags: List<String>? = null,
val includeItemTypes: List<BaseItemKind>? = null,
val videoTypes: List<FilterVideoType>? = null,
val years: List<Int>? = null,
val decades: List<Int>? = null,
val override: GetItemsFilterOverride = GetItemsFilterOverride.NONE,
) {
/**
* Returns how many of filters are actually being used in this [GetItemsFilter]
*/
fun countFilters(filterOptions: List<ItemFilterBy<*>>): 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<ItemFilterBy<*>>): 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 {

View file

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

View file

@ -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>(LoadingState.Loading)
val backgroundLoading = MutableLiveData<LoadingState>(LoadingState.Loading)
val pager = MutableLiveData<List<BaseItem?>>(listOf())
val sortAndDirection = MutableLiveData<SortAndDirection>()
val filter = MutableLiveData<GetItemsFilter>(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<FilterValueOption> =
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<Int>()
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<ItemFilterBy<*>> = 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<ItemFilterBy<*>> = 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<ItemFilterBy<*>> = listOf(),
onFilterChange: (GetItemsFilter) -> Unit = {},
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
) {
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(

View file

@ -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<ItemFilterBy<*>>,
current: GetItemsFilter,
onFilterChange: (GetItemsFilter) -> Unit,
getPossibleValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
modifier: Modifier = Modifier,
) {
var dropDown by remember { mutableStateOf(false) }
var nestedDropDown by remember { mutableStateOf<ItemFilterBy<*>?>(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<Any>
val currentValue = remember(current) { filterOption.get(current) }
var possibleValues by remember { mutableStateOf<List<FilterValueOption>?>(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<UUID>)
.orEmpty()
.contains(value.value)
}
FavoriteFilter,
PlayedFilter,
-> (currentValue as? Boolean) == value.name.toBoolean()
OfficialRatingFilter -> {
(currentValue as? List<String>)
.orEmpty()
.contains(value.name)
}
VideoTypeFilter ->
(currentValue as? List<FilterVideoType>)
.orEmpty()
.contains(value.value)
YearFilter,
DecadeFilter,
->
(currentValue as? List<Int>)
.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<UUID>).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<String>).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<FilterVideoType>).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<Int>).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),
)
}
}
}

View file

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

View file

@ -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 = {

View file

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

View file

@ -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<ItemFilterBy<*>> = DefaultFilterOptions,
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
var showHeader by remember { mutableStateOf(true) }
@ -53,5 +56,6 @@ fun CollectionFolderGeneric(
},
params = params,
playEnabled = playEnabled,
filterOptions = filterOptions,
)
}

View file

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

View file

@ -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,
)

View file

@ -38,4 +38,5 @@
<string name="fa_volume_low" translatable="false">&#xf027;</string>
<string name="fa_ellipsis" translatable="false">&#xf141;</string>
<string name="fa_ellipsis_vertical" translatable="false">&#xf142;</string>
<string name="fa_filter" translatable="false">&#xf0b0;</string>
</resources>

View file

@ -319,6 +319,11 @@
<string name="skip_segment_recap">Skip Recap</string>
<string name="skip_segment_outro">Skip Outro</string>
<string name="skip_segment_intro">Skip Intro</string>
<string name="played">Played</string>
<string name="filter">Filter</string>
<string name="year">Year</string>
<string name="decade">Decade</string>
<string name="remove">Remove</string>
<string-array name="theme_song_volume">
<item>Disabled</item>