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