From fc4954b5611f4f2c2950c4776d69c74fac63374e Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:57:26 -0400 Subject: [PATCH] Add ability to customize the navigation drawer menu (#60) Adds support for customizing the items listed on the left side navigation drawer menu. Each item can be toggled on/off in settings. The hidden ones are still accessible under "More" on the nav drawer, which only appears if some items are hidden. Additionally, if any of menu items are hidden, their "latest" content will not appear on the home page. The setting is stored per user profile. --- .../5.json | 241 ++++++++++++++++++ .../damontecres/wholphin/data/AppDatabase.kt | 12 +- .../wholphin/data/JellyfinServer.kt | 4 +- .../wholphin/data/NavDrawerItemRepository.kt | 66 +++++ .../wholphin/data/ServerPreferencesDao.kt | 18 ++ .../wholphin/data/ServerRepository.kt | 6 +- .../wholphin/data/model/ServerPreferences.kt | 28 ++ .../wholphin/hilt/DatabaseModule.kt | 5 + .../wholphin/preferences/AppPreference.kt | 15 ++ .../wholphin/ui/main/HomeViewModel.kt | 107 ++++---- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 230 +++++++++++------ .../ui/preferences/ComposablePreference.kt | 46 +--- .../ui/preferences/MultiChoicePreference.kt | 93 +++++++ .../ui/preferences/PreferencesContent.kt | 21 ++ .../ui/preferences/PreferencesViewModel.kt | 63 ++++- app/src/main/res/values/fa_strings.xml | 2 + app/src/main/res/values/strings.xml | 3 + 17 files changed, 776 insertions(+), 184 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/5.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/ServerPreferencesDao.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/5.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/5.json new file mode 100644 index 00000000..518309e1 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/5.json @@ -0,0 +1,241 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "59351f2b9819f90fab82ff0f353c79a5", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, 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 + } + ], + "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" + ] + } + ] + } + ], + "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, '59351f2b9819f90fab82ff0f353c79a5')" + ] + } +} 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 3707309b..f825891c 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 @@ -8,20 +8,26 @@ import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID @Database( - entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class], - version = 4, + entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class], + version = 5, exportSchema = true, - autoMigrations = [AutoMigration(3, 4)], + autoMigrations = [ + AutoMigration(3, 4), + AutoMigration(4, 5), + ], ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun serverDao(): JellyfinServerDao abstract fun itemPlaybackDao(): ItemPlaybackDao + + abstract fun serverPreferencesDao(): ServerPreferencesDao } class Converters { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServer.kt index 5f00bda7..ebfa77d4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServer.kt @@ -81,7 +81,7 @@ interface JellyfinServerDao { fun updateUser(user: JellyfinUser): Int @Transaction - fun addOrUpdateUser(user: JellyfinUser) { + fun addOrUpdateUser(user: JellyfinUser): JellyfinUser { val result = addUser(user) if (result == -1L) { val toSave = @@ -92,7 +92,9 @@ interface JellyfinServerDao { user } updateUser(toSave) + return toSave } + return user.copy(rowId = result.toInt()) } @Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId") diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt new file mode 100644 index 00000000..11a81a62 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt @@ -0,0 +1,66 @@ +package com.github.damontecres.wholphin.data + +import android.content.Context +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem +import com.github.damontecres.wholphin.data.model.NavPinType +import com.github.damontecres.wholphin.ui.nav.NavDrawerItem +import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem +import com.github.damontecres.wholphin.util.supportedCollectionTypes +import dagger.hilt.android.qualifiers.ApplicationContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.api.CollectionType +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NavDrawerItemRepository + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val serverPreferencesDao: ServerPreferencesDao, + ) { + suspend fun getNavDrawerItems(): List { + val user = serverRepository.currentUser + val userViews = + api.userViewsApi + .getUserViews(userId = user?.id) + .content.items + + val builtins = listOf(NavDrawerItem.Favorites) + val libraries = + userViews + .filter { it.collectionType in supportedCollectionTypes } + .map { + ServerNavDrawerItem( + itemId = it.id, + name = it.name ?: it.id.toString(), + destination = BaseItem.from(it, api).destination(), + type = it.collectionType ?: CollectionType.UNKNOWN, + ) + } + return builtins + libraries + } + + suspend fun getFilteredNavDrawerItems(items: List): List { + val user = serverRepository.currentUser + val navDrawerPins = + user + ?.let { + serverPreferencesDao.getNavDrawerPinnedItems(it) + }.orEmpty() + val filtered = items.filter { navDrawerPins.isPinned(it.id) } + if (items.size != filtered.size) { + // Some were filtered out, check if should include More + if (navDrawerPins.isPinned(NavDrawerItem.More.id)) { + return filtered + listOf(NavDrawerItem.More) + } + } + return filtered + } + } + +fun List.isPinned(id: String) = (firstOrNull { it.itemId == id }?.type ?: NavPinType.PINNED) == NavPinType.PINNED diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerPreferencesDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerPreferencesDao.kt new file mode 100644 index 00000000..870086d0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerPreferencesDao.kt @@ -0,0 +1,18 @@ +package com.github.damontecres.wholphin.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem + +@Dao +interface ServerPreferencesDao { + fun getNavDrawerPinnedItems(user: JellyfinUser): List = getNavDrawerPinnedItems(user.rowId) + + @Query("SELECT * from NavDrawerPinnedItem WHERE userId=:userId") + fun getNavDrawerPinnedItems(userId: Int): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun saveNavDrawerPinnedItems(vararg items: NavDrawerPinnedItem) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index d6279eb5..e9c812fa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -82,15 +82,13 @@ class ServerRepository val sysInfo by apiClient.systemApi.getSystemInfo() val updatedServer = server.copy(name = sysInfo.serverName) - val updatedUser = + var updatedUser = user.copy( id = userDto.id, name = userDto.name, ) - if (updatedUser.rowId <= 0) { - } serverDao.addOrUpdateServer(updatedServer) - serverDao.addOrUpdateUser(updatedUser) + updatedUser = serverDao.addOrUpdateUser(updatedUser) userPreferencesDataStore.updateData { it .toBuilder() diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt new file mode 100644 index 00000000..15ef8003 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt @@ -0,0 +1,28 @@ +package com.github.damontecres.wholphin.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import com.github.damontecres.wholphin.data.JellyfinUser + +enum class NavPinType { + PINNED, + UNPINNED, +} + +@Entity( + foreignKeys = [ + ForeignKey( + entity = JellyfinUser::class, + parentColumns = arrayOf("rowId"), + childColumns = arrayOf("userId"), + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE, + ), + ], + primaryKeys = ["userId", "itemId"], +) +data class NavDrawerPinnedItem( + val userId: Int, + val itemId: String, + val type: NavPinType, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/hilt/DatabaseModule.kt index 845f9a46..ab1fb0e5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/hilt/DatabaseModule.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.AppDatabase import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.Migrations +import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer import dagger.Module @@ -46,6 +47,10 @@ object DatabaseModule { @Singleton fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao() + @Provides + @Singleton + fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 68e89992..dcfe820a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -526,6 +526,14 @@ sealed interface AppPreference { getter = { }, setter = { prefs, _ -> prefs }, ) + + val UserPinnedNavDrawerItems = + AppClickablePreference( + title = R.string.nav_drawer_pins, + summary = R.string.nav_drawer_pins_summary, + getter = { }, + setter = { prefs, _ -> prefs }, + ) } } @@ -556,6 +564,13 @@ val basicPreferences = AppPreference.SkipBackOnResume, ), ), + PreferenceGroup( + title = R.string.profile_specific_settings, + preferences = + listOf( + AppPreference.UserPinnedNavDrawerItems, + ), + ), PreferenceGroup( title = R.string.about, preferences = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 733dca2c..22cb2db4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -3,10 +3,13 @@ package com.github.damontecres.wholphin.ui.main import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.NavDrawerItemRepository +import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.nav.NavigationManager +import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.supportItemKinds @@ -19,7 +22,6 @@ import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.UserDto @@ -36,6 +38,8 @@ class HomeViewModel constructor( val api: ApiClient, val navigationManager: NavigationManager, + val serverRepository: ServerRepository, + val navDrawerItemRepository: NavDrawerItemRepository, ) : ViewModel() { val loadingState = MutableLiveData(LoadingState.Pending) val homeRows = MutableLiveData>() @@ -52,64 +56,44 @@ class HomeViewModel ), ) { Timber.d("init HomeViewModel") - val user by api.userApi.getCurrentUser() -// val displayPrefs = -// api.displayPreferencesApi -// .getDisplayPreferences( -// displayPreferencesId = "usersettings", -// client = "emby", -// ).content - val homeSections = - listOf( - HomeSection.RESUME, - HomeSection.NEXT_UP, - HomeSection.LATEST_MEDIA, - ) - // TODO use display preferences? -// displayPrefs.customPrefs.entries -// .filter { it.key.startsWith("homesection") && it.value.isNotNullOrBlank() } -// .sortedBy { it.key } -// .map { HomeSection.fromKey(it.value ?: "") } -// .filter { -// it in -// setOf( -// HomeSection.LATEST_MEDIA, -// HomeSection.NEXT_UP, -// HomeSection.RESUME, -// ) -// } + serverRepository.currentUserDto?.let { userDto -> + val includedIds = + navDrawerItemRepository + .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) + .filter { it is ServerNavDrawerItem } + .map { (it as ServerNavDrawerItem).itemId } + // TODO data is fetched all together which may be slow for large servers + val resume = getResume(userDto.id, limit) + val nextUp = getNextUp(userDto.id, limit, prefs.enableRewatchingNextUp) + val latest = getLatest(userDto, limit, includedIds) - // TODO data is fetched all together which may be slow for large servers - val resume = getResume(user.id, limit) - val nextUp = getNextUp(user.id, limit, prefs.enableRewatchingNextUp) - val latest = getLatest(user, limit) - - val homeRows = - if (prefs.combineContinueNext) { - listOf( - HomeRow( - section = HomeSection.NEXT_UP, - items = resume + nextUp, - ), - *latest.toTypedArray(), - ) - } else { - listOf( - HomeRow( - section = HomeSection.RESUME, - items = resume, - ), - HomeRow( - section = HomeSection.NEXT_UP, - items = nextUp, - ), - *latest.toTypedArray(), - ) + val homeRows = + if (prefs.combineContinueNext) { + listOf( + HomeRow( + section = HomeSection.NEXT_UP, + items = resume + nextUp, + ), + *latest.toTypedArray(), + ) + } else { + listOf( + HomeRow( + section = HomeSection.RESUME, + items = resume, + ), + HomeRow( + section = HomeSection.NEXT_UP, + items = nextUp, + ), + *latest.toTypedArray(), + ) + } + withContext(Dispatchers.Main) { + this@HomeViewModel.homeRows.value = homeRows + loadingState.value = LoadingState.Success } - withContext(Dispatchers.Main) { - this@HomeViewModel.homeRows.value = homeRows - loadingState.value = LoadingState.Success } } } @@ -162,11 +146,16 @@ class HomeViewModel private suspend fun getLatest( user: UserDto, limit: Int, + includedIds: List, ): List { val latestMediaIncludes = - user.configuration?.orderedViews.orEmpty().toMutableList().apply { - removeAll(user.configuration?.latestItemsExcludes.orEmpty()) - } + user.configuration + ?.orderedViews + .orEmpty() + .toMutableList() + .apply { + removeAll(user.configuration?.latestItemsExcludes.orEmpty()) + }.filter { includedIds.contains(it) } val views by api.userViewsApi.getUserViews() val rows = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index fb84fc17..195ed5b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.ui.nav import android.content.Context import androidx.activity.compose.BackHandler -import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource @@ -17,10 +16,13 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember @@ -49,6 +51,7 @@ import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.NavigationDrawer import androidx.tv.material3.NavigationDrawerItem +import androidx.tv.material3.NavigationDrawerItemDefaults import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text @@ -57,26 +60,20 @@ import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.JellyfinServer import com.github.damontecres.wholphin.data.JellyfinUser -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.spacedByWithFooter +import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.DeviceProfile -import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -84,45 +81,25 @@ import javax.inject.Inject class NavDrawerViewModel @Inject constructor( - @param:ApplicationContext val context: Context, - val serverRepository: ServerRepository, - val api: ApiClient, + private val navDrawerItemRepository: NavDrawerItemRepository, val navigationManager: NavigationManager, ) : ViewModel() { + private var all: List? = null + val moreLibraries = MutableLiveData>(null) val libraries = MutableLiveData>(listOf()) - val selectedIndex = MutableLiveData(-1) + val selectedIndex = MutableLiveData(-1) + val showMore = MutableLiveData(false) + + fun init() { + viewModelScope.launchIO { + val all = all ?: navDrawerItemRepository.getNavDrawerItems() + this@NavDrawerViewModel.all = all + val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) - init { - viewModelScope.launch(Dispatchers.IO + ExceptionHandler(true)) { - val userViews = - api.userViewsApi - .getUserViews() - .content.items - val libraries = - userViews - .filter { it.collectionType in supportedCollectionTypes } - .map { - NavDrawerItem( - id = it.id, - name = it.name ?: it.id.toString(), - destination = BaseItem.from(it, api).destination(), - type = it.collectionType ?: CollectionType.UNKNOWN, - iconStringRes = null, - ) - } - val extra = - listOf( - NavDrawerItem( - id = UUID.randomUUID(), - name = context.getString(R.string.favorites), - destination = Destination.Favorites, - type = CollectionType.UNKNOWN, - iconStringRes = R.string.fa_heart, - ), - ) - Timber.d("Got ${userViews.size} user views filtered to ${libraries.size}") withContext(Dispatchers.Main) { - this@NavDrawerViewModel.libraries.value = extra + libraries + this@NavDrawerViewModel.moreLibraries.value = + all.toMutableList().apply { removeAll(libraries) } + this@NavDrawerViewModel.libraries.value = libraries } } } @@ -130,15 +107,42 @@ class NavDrawerViewModel fun setIndex(index: Int) { selectedIndex.value = index } + + fun setShowMore(value: Boolean) { + showMore.value = value + } } -data class NavDrawerItem( - val id: UUID, +sealed interface NavDrawerItem { + val id: String + + fun name(context: Context): String + + object Favorites : NavDrawerItem { + override val id: String + get() = "a_favorites" + + override fun name(context: Context): String = context.getString(R.string.favorites) + } + + object More : NavDrawerItem { + override val id: String + get() = "a_more" + + override fun name(context: Context): String = context.getString(R.string.more) + } +} + +data class ServerNavDrawerItem( + val itemId: UUID, val name: String, val destination: Destination, val type: CollectionType, - @param:StringRes val iconStringRes: Int?, -) +) : NavDrawerItem { + override val id: String = "s_" + itemId.toServerString() + + override fun name(context: Context): String = name +} /** * Display the left side navigation drawer with [DestinationContent] on the right @@ -162,17 +166,49 @@ fun NavDrawer( val scope = rememberCoroutineScope() val context = LocalContext.current val drawerFocusRequester = remember { FocusRequester() } + val focusRequester = remember { FocusRequester() } // If the user presses back while on the home page, open the nav drawer, another back press will quit the app BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) { drawerState.setValue(DrawerValue.Open) drawerFocusRequester.requestFocus() } + val moreLibraries by viewModel.moreLibraries.observeAsState(listOf()) val libraries by viewModel.libraries.observeAsState(listOf()) + LaunchedEffect(Unit) { viewModel.init() } + val showMore by viewModel.showMore.observeAsState(false) +// val libraries = if (showPinnedOnly) pinnedLibraries else allLibraries // A negative index is a built in page, >=0 is a library val selectedIndex by viewModel.selectedIndex.observeAsState(-1) - val focusRequester = remember { FocusRequester() } + + fun setShowMore(value: Boolean) { + viewModel.setShowMore(value) + } + + BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) { + setShowMore(false) + } + + val onClick = { index: Int, item: NavDrawerItem -> + when (item) { + NavDrawerItem.Favorites -> { + viewModel.setIndex(index) + viewModel.navigationManager.navigateToFromDrawer( + Destination.Favorites, + ) + } + + NavDrawerItem.More -> { + setShowMore(!showMore) + } + + is ServerNavDrawerItem -> { + viewModel.setIndex(index) + viewModel.navigationManager.navigateToFromDrawer(item.destination) + } + } + } NavigationDrawer( modifier = @@ -205,6 +241,7 @@ fun NavDrawer( onClick = { viewModel.navigationManager.navigateTo(Destination.UserList) }, + modifier = Modifier.animateItem(), ) } item { @@ -217,10 +254,11 @@ fun NavDrawer( viewModel.navigationManager.navigateToFromDrawer(Destination.Search) }, modifier = - Modifier.ifElse( - selectedIndex == -2, - Modifier.focusRequester(focusRequester), - ), + Modifier + .ifElse( + selectedIndex == -2, + Modifier.focusRequester(focusRequester), + ).animateItem(), ) } item { @@ -237,27 +275,44 @@ fun NavDrawer( } }, modifier = - Modifier.ifElse( - selectedIndex == -1, - Modifier.focusRequester(focusRequester), - ), + Modifier + .ifElse( + selectedIndex == -1, + Modifier.focusRequester(focusRequester), + ).animateItem(), ) } itemsIndexed(libraries) { index, it -> NavItem( library = it, selected = selectedIndex == index, - onClick = { - viewModel.setIndex(index) - viewModel.navigationManager.navigateToFromDrawer(it.destination) - }, + moreExpanded = showMore, + onClick = { onClick.invoke(index, it) }, modifier = - Modifier.ifElse( - selectedIndex == index, - Modifier.focusRequester(focusRequester), - ), + Modifier + .ifElse( + selectedIndex == index, + Modifier.focusRequester(focusRequester), + ).animateItem(), ) } + if (showMore) { + itemsIndexed(moreLibraries) { index, it -> + NavItem( + library = it, + selected = selectedIndex == (index + libraries.size), + moreExpanded = showMore, + onClick = { onClick.invoke(index + libraries.size, it) }, + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + modifier = + Modifier + .ifElse( + selectedIndex == (index + libraries.size), + Modifier.focusRequester(focusRequester), + ).animateItem(), + ) + } + } item { IconNavItem( text = "Settings", @@ -270,7 +325,7 @@ fun NavDrawer( ), ) }, - modifier = Modifier, + modifier = Modifier.animateItem(), ) } } @@ -339,20 +394,29 @@ fun NavigationDrawerScope.NavItem( library: NavDrawerItem, onClick: () -> Unit, selected: Boolean, + moreExpanded: Boolean, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + containerColor: Color = Color.Unspecified, ) { + val context = LocalContext.current val icon = - when (library.type) { - CollectionType.MOVIES -> R.string.fa_film - CollectionType.TVSHOWS -> R.string.fa_tv - CollectionType.HOMEVIDEOS -> R.string.fa_video - CollectionType.LIVETV -> R.string.fa_tv - CollectionType.MUSIC -> R.string.fa_music - CollectionType.BOXSETS -> R.string.fa_open_folder - CollectionType.PLAYLISTS -> R.string.fa_list_ul - else -> library.iconStringRes - } ?: R.string.fa_film + when (library) { + NavDrawerItem.Favorites -> R.string.fa_heart + NavDrawerItem.More -> R.string.fa_ellipsis + + is ServerNavDrawerItem -> + when (library.type) { + CollectionType.MOVIES -> R.string.fa_film + CollectionType.TVSHOWS -> R.string.fa_tv + CollectionType.HOMEVIDEOS -> R.string.fa_video + CollectionType.LIVETV -> R.string.fa_tv + CollectionType.MUSIC -> R.string.fa_music + CollectionType.BOXSETS -> R.string.fa_open_folder + CollectionType.PLAYLISTS -> R.string.fa_list_ul + else -> R.string.fa_film + } + } val isFocused = interactionSource.collectIsFocusedAsState().value val color = when { @@ -364,6 +428,10 @@ fun NavigationDrawerScope.NavItem( modifier = modifier, selected = false, onClick = onClick, + colors = + NavigationDrawerItemDefaults.colors( + containerColor = containerColor, + ), leadingContent = { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text( @@ -375,11 +443,19 @@ fun NavigationDrawerScope.NavItem( ) } }, + trailingContent = { + if (library is NavDrawerItem.More) { + Icon( + imageVector = if (moreExpanded) Icons.Default.ArrowDropDown else Icons.Default.KeyboardArrowLeft, + contentDescription = null, + ) + } + }, interactionSource = null, ) { Text( modifier = Modifier, - text = library.name ?: library.id.toString(), + text = library.name(context), maxLines = 1, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt index b1b3fcd2..b19d9390 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt @@ -34,7 +34,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Switch import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R @@ -53,6 +52,7 @@ import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch +import java.util.SortedSet @Suppress("UNCHECKED_CAST") @Composable @@ -197,7 +197,7 @@ fun ComposablePreference( } is AppMultiChoicePreference<*> -> { - val values = stringArrayResource(preference.displayValues).toList() + val values = stringArrayResource(preference.displayValues).toSortedSet() val summary = preference.summary?.let { stringResource(it) } ?: preference.summary(context, value) @@ -207,46 +207,14 @@ fun ComposablePreference( list.addAll(value as List) list } - - val onClick = { item: Any -> - if (selectedValues.contains(item)) { - selectedValues.remove(item) - } else { - selectedValues.add(item) - } - onValueChange.invoke(selectedValues.toList() as T) - } - - ClickPreference( + MultiChoicePreference( + possibleValues = values as SortedSet, + selectedValues = selectedValues, title = title, summary = summary, - onClick = { - dialogParams = - DialogParams( - title = title, - fromLongClick = false, - items = - values.mapIndexed { index, it -> - val item = preference.allValues[index]!! - DialogItem( - headlineContent = { Text(it) }, - trailingContent = { - Switch( - checked = selectedValues.contains(item), - onCheckedChange = { - onClick.invoke(item) - }, - ) - }, - onClick = { - onClick.invoke(item) - }, - ) - }, - ) + onValueChange = { + onValueChange.invoke(selectedValues.toList() as T) }, - interactionSource = interactionSource, - modifier = modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt new file mode 100644 index 00000000..a4686b10 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt @@ -0,0 +1,93 @@ +package com.github.damontecres.wholphin.ui.preferences + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup + +@Composable +fun MultiChoicePreference( + title: String, + summary: String?, + possibleValues: Set, + selectedValues: Set, + onValueChange: (List) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + valueDisplay: @Composable (item: T) -> Unit = { Text(it.toString()) }, +) { +// val values = stringArrayResource(preference.displayValues).toList() +// val summary = +// preference.summary?.let { stringResource(it) } +// ?: preference.summary(context, value) + val selectedValues = + remember { + val list = mutableStateSetOf() + list.addAll(selectedValues) + list + } + + val onClick = { item: T -> + if (selectedValues.contains(item)) { + selectedValues.remove(item) + } else { + selectedValues.add(item) + } + onValueChange.invoke(selectedValues.toList()) + } + + var dialogParams by remember { mutableStateOf(null) } + + ClickPreference( + title = title, + summary = summary, + onClick = { + dialogParams = + DialogParams( + title = title, + fromLongClick = false, + items = + possibleValues.mapIndexed { index, item -> + DialogItem( + headlineContent = { valueDisplay.invoke(item) }, + trailingContent = { + Switch( + checked = selectedValues.contains(item), + onCheckedChange = { + onClick.invoke(item) + }, + ) + }, + onClick = { + onClick.invoke(item) + }, + ) + }, + ) + }, + interactionSource = interactionSource, + modifier = modifier, + ) + AnimatedVisibility(dialogParams != null) { + dialogParams?.let { + DialogPopup( + showDialog = true, + title = it.title, + dialogItems = it.items, + onDismissRequest = { dialogParams = null }, + waitToLoad = false, + dismissOnClick = false, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index cdd1f7e4..71d949ae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -69,6 +69,9 @@ fun PreferencesContent( var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } val state = rememberLazyListState() var preferences by remember { mutableStateOf(initialPreferences) } + + val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) + LaunchedEffect(Unit) { viewModel.preferenceDataStore.data.collect { preferences = it @@ -262,6 +265,24 @@ fun PreferencesContent( ) } + AppPreference.UserPinnedNavDrawerItems -> { + val selectedItems = + navDrawerPins.keys.mapNotNull { + if (navDrawerPins[it] ?: false) it else null + } + MultiChoicePreference( + title = stringResource(pref.title), + summary = pref.summary(context, null), + possibleValues = navDrawerPins.keys, + selectedValues = selectedItems.toSet(), + onValueChange = { newSelectedItems -> + viewModel.updatePins(newSelectedItems) + }, + ) { + Text(it.name(context)) + } + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 462549dc..4cb9de9f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -1,9 +1,21 @@ package com.github.damontecres.wholphin.ui.preferences import androidx.datastore.core.DataStore +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.NavDrawerItemRepository +import com.github.damontecres.wholphin.data.ServerPreferencesDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.isPinned +import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem +import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavigationManager +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -15,5 +27,54 @@ class PreferencesViewModel val preferenceDataStore: DataStore, val navigationManager: NavigationManager, val rememberTabManager: RememberTabManager, + val serverRepository: ServerRepository, + val navDrawerItemRepository: NavDrawerItemRepository, + val serverPreferencesDao: ServerPreferencesDao, ) : ViewModel(), - RememberTabManager by rememberTabManager + RememberTabManager by rememberTabManager { + private lateinit var allNavDrawerItems: List + val navDrawerPins = MutableLiveData>(mapOf()) + + init { + viewModelScope.launchIO { + serverRepository.currentUser?.let { user -> + allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems() + val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) + val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } + this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins) + } + } + } + + fun updatePins(newSelectedItems: List) { + viewModelScope.launchIO(ExceptionHandler(true)) { + serverRepository.currentUser?.let { user -> + val disabledItems = + mutableListOf().apply { + addAll(allNavDrawerItems) + removeAll(newSelectedItems) + } + val enabledItems = newSelectedItems.toSet() + val toSave = + disabledItems.map { + NavDrawerPinnedItem( + user.rowId, + it.id, + NavPinType.UNPINNED, + ) + } + + enabledItems.map { + NavDrawerPinnedItem( + user.rowId, + it.id, + NavPinType.PINNED, + ) + } + serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) + val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) + val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } + this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins) + } + } + } + } diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 8d09fb92..70099f95 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -36,4 +36,6 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c74b09f..bec45c38 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -88,5 +88,8 @@ Unfavorite Favorite Favorites + Customize Navigation Drawer Items + User Profile Settings + Choose the default items to show, others will be hidden