From cb6ed0e8821e3f4db269f4645037af412ec35474 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 12 Feb 2026 13:25:52 -0500 Subject: [PATCH 1/3] Convert nav drawer to use updating flow --- .../wholphin/services/NavDrawerService.kt | 131 ++++++++++++++ .../wholphin/services/hilt/AppModule.kt | 9 + .../damontecres/wholphin/ui/nav/NavDrawer.kt | 169 ++++++------------ 3 files changed, 193 insertions(+), 116 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt new file mode 100644 index 000000000..1968d44b3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -0,0 +1,131 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.lifecycle.asFlow +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.BaseItem +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.ui.nav.Destination +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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.UserDto +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NavDrawerService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:DefaultCoroutineScope private val coroutineScope: CoroutineScope, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val serverPreferencesDao: ServerPreferencesDao, + private val seerrServerRepository: SeerrServerRepository, + ) { + private val _state = MutableStateFlow(NavDrawerItemState.EMPTY) + val state: StateFlow = _state + + init { + serverRepository.currentUser + .asFlow() + .combine(serverRepository.currentUserDto.asFlow()) { user, userDto -> + Pair(user, userDto) + }.onEach { (user, userDto) -> + Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id) + _state.update { + it.copy( + items = emptyList(), + moreItems = emptyList(), + ) + } + if (user != null && userDto != null && user.id == userDto.id) { + updateNavDrawer(user, userDto) + } + }.launchIn(coroutineScope) + seerrServerRepository.active + .onEach { discoverActive -> + _state.update { it.copy(discoverEnabled = discoverActive) } + }.launchIn(coroutineScope) + } + + private suspend fun updateNavDrawer( + user: JellyfinUser, + userDto: UserDto, + ) { + val tvAccess = userDto.policy?.enableLiveTvAccess ?: false + val userViews = + api.userViewsApi + .getUserViews(userId = user.id) + .content.items + val recordingFolders = + if (tvAccess) { + api.liveTvApi + .getRecordingFolders(userId = user.id) + .content.items + .map { it.id } + .toSet() + } else { + setOf() + } + + val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) + + val libraries = + userViews + .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } + .map { + val destination = + if (it.id in recordingFolders) { + Destination.Recordings(it.id) + } else { + BaseItem.from(it, api).destination() + } + ServerNavDrawerItem( + itemId = it.id, + name = it.name ?: it.id.toString(), + destination = destination, + type = it.collectionType ?: CollectionType.UNKNOWN, + ) + } + val allItems = builtins + libraries + + val navDrawerPins = serverPreferencesDao.getNavDrawerPinnedItems(user) + val filtered = allItems.groupBy { navDrawerPins.isPinned(it.id) } + val items = filtered[true].orEmpty() + val moreItems = filtered[false].orEmpty() + _state.update { + it.copy( + items = items, + moreItems = moreItems, + ) + } + } + } + +data class NavDrawerItemState( + val items: List, + val moreItems: List, + val discoverEnabled: Boolean, +) { + companion object { + val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index f207562ce..52c37413a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -44,6 +44,10 @@ annotation class StandardOkHttpClient @Retention(AnnotationRetention.BINARY) annotation class IoCoroutineScope +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultCoroutineScope + @Module @InstallIn(SingletonComponent::class) object AppModule { @@ -177,6 +181,11 @@ object AppModule { @IoCoroutineScope fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Provides + @Singleton + @DefaultCoroutineScope + fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Provides @Singleton fun workManager( 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 6eac3206d..60a0bebc0 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 @@ -27,8 +27,8 @@ 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.ReadOnlyComposable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember @@ -54,7 +54,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.findViewTreeViewModelStoreOwner -import androidx.lifecycle.viewModelScope import androidx.tv.material3.DrawerState import androidx.tv.material3.DrawerValue import androidx.tv.material3.Icon @@ -72,6 +71,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SetupDestination @@ -79,23 +79,16 @@ import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.components.TimeDisplay 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.setValueOnMain import com.github.damontecres.wholphin.ui.setup.UserIconCardImage import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.CollectionType -import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -104,80 +97,17 @@ class NavDrawerViewModel @Inject constructor( private val api: ApiClient, + private val navDrawerService: NavDrawerService, private val navDrawerItemRepository: NavDrawerItemRepository, val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val backdropService: BackdropService, private val seerrServerRepository: SeerrServerRepository, ) : ViewModel() { - val moreLibraries = MutableLiveData>(null) - val libraries = MutableLiveData>(listOf()) + val state = navDrawerService.state val selectedIndex = MutableLiveData(-1) - val showMore = MutableLiveData(false) - - init { - seerrServerRepository.active - .onEach { - init() - }.launchIn(viewModelScope) - } - - fun init() { - viewModelScope.launchIO { - val all = navDrawerItemRepository.getNavDrawerItems() - val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) - val moreLibraries = all.toMutableList().apply { removeAll(libraries) } - - withContext(Dispatchers.Main) { - this@NavDrawerViewModel.moreLibraries.value = moreLibraries - this@NavDrawerViewModel.libraries.value = libraries - } - val asDestinations = - ( - libraries + - listOf( - NavDrawerItem.More, - NavDrawerItem.Discover, - ) + moreLibraries - ).map { - if (it is ServerNavDrawerItem) { - it.destination - } else if (it is NavDrawerItem.Favorites) { - Destination.Favorites - } else if (it is NavDrawerItem.Discover) { - Destination.Discover - } else { - null - } - } - - val backstack = navigationManager.backStack.toList().reversed() - for (i in 0..= 0) { - idx - } else { - null - } - } - Timber.v("Found $index => $key") - if (index != null) { - selectedIndex.setValueOnMain(index) - break - } - } - } - } - } + val moreExpanded = MutableLiveData(false) fun onClickDrawerItem( index: Int, @@ -193,7 +123,7 @@ class NavDrawerViewModel } NavDrawerItem.More -> { - setShowMore(!showMore.value!!) + setShowMore(!moreExpanded.value!!) } NavDrawerItem.Discover -> { @@ -215,7 +145,7 @@ class NavDrawerViewModel } fun setShowMore(value: Boolean) { - showMore.value = value + moreExpanded.value = value } fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id) @@ -289,15 +219,12 @@ fun NavDrawer( drawerState.setValue(DrawerValue.Open) focusRequester.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) - // A negative index is a built in page, >=0 is a library + val state by viewModel.state.collectAsState() + val moreExpanded by viewModel.moreExpanded.observeAsState(false) + // A negative index is a built-in page, >=0 is a library val selectedIndex by viewModel.selectedIndex.observeAsState(-1) - BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) { + BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) { viewModel.setShowMore(false) } @@ -412,51 +339,61 @@ fun NavDrawer( ), ) } - itemsIndexed(libraries) { index, it -> - val interactionSource = remember { MutableInteractionSource() } - NavItem( - library = it, - selected = selectedIndex == index, - moreExpanded = showMore, - drawerOpen = isOpen, - interactionSource = interactionSource, - onClick = { - viewModel.onClickDrawerItem(index, it) - }, - modifier = - Modifier - .ifElse( - selectedIndex == index, - Modifier.focusRequester(focusRequester), - ), - ) - } - if (showMore) { - itemsIndexed(moreLibraries) { index, it -> - val adjustedIndex = (index + libraries.size + 1) + itemsIndexed(state.items) { index, it -> + if (it !is NavDrawerItem.Discover || state.discoverEnabled) { val interactionSource = remember { MutableInteractionSource() } NavItem( library = it, - selected = selectedIndex == adjustedIndex, - moreExpanded = showMore, + selected = selectedIndex == index, + moreExpanded = moreExpanded, drawerOpen = isOpen, - onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) }, - containerColor = - if (isOpen) { - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - } else { - Color.Unspecified - }, interactionSource = interactionSource, + onClick = { + viewModel.onClickDrawerItem(index, it) + }, modifier = Modifier .ifElse( - selectedIndex == adjustedIndex, + selectedIndex == index, Modifier.focusRequester(focusRequester), ), ) } } + if (moreExpanded) { + itemsIndexed(state.moreItems) { index, it -> + val adjustedIndex = + remember(state) { (index + state.items.size + 1) } + if (it !is NavDrawerItem.Discover || state.discoverEnabled) { + val interactionSource = remember { MutableInteractionSource() } + NavItem( + library = it, + selected = selectedIndex == adjustedIndex, + moreExpanded = moreExpanded, + drawerOpen = isOpen, + onClick = { + viewModel.onClickDrawerItem( + adjustedIndex, + it, + ) + }, + containerColor = + if (isOpen) { + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) + } else { + Color.Unspecified + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + selectedIndex == adjustedIndex, + Modifier.focusRequester(focusRequester), + ), + ) + } + } + } item { val interactionSource = remember { MutableInteractionSource() } IconNavItem( From d0ace78b8537b452cd48114d1c5b2b474f142af2 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 12 Feb 2026 13:49:57 -0500 Subject: [PATCH 2/3] Add more button & sort order --- .../31.json | 642 ++++++++++++++++++ .../damontecres/wholphin/data/AppDatabase.kt | 3 +- .../wholphin/data/ServerPreferencesDao.kt | 2 +- .../wholphin/data/model/ServerPreferences.kt | 2 + .../wholphin/services/NavDrawerService.kt | 3 +- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 22 + .../ui/preferences/PreferencesViewModel.kt | 30 +- 7 files changed, 684 insertions(+), 20 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json new file mode 100644 index 000000000..b719e5f40 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json @@ -0,0 +1,642 @@ +{ + "formatVersion": 1, + "database": { + "version": 31, + "identityHash": "c6829d764ec85321ab3be9905d6c0e3a", + "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, `pin` 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" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "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, `order` INTEGER NOT NULL DEFAULT -1, 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 + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "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 '{}', `viewOptions` TEXT, 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": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "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" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), 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": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "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, 'c6829d764ec85321ab3be9905d6c0e3a')" + ] + } +} 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 a3629843e..b3f33bedb 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 @@ -40,7 +40,7 @@ import java.util.UUID SeerrUser::class, ], - version = 30, + version = 31, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -54,6 +54,7 @@ import java.util.UUID AutoMigration(11, 12), AutoMigration(12, 20), AutoMigration(20, 30), + AutoMigration(30, 31), ], ) @TypeConverters(Converters::class) 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 index 351fabfe1..38a4452f0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerPreferencesDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerPreferencesDao.kt @@ -11,7 +11,7 @@ import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem interface ServerPreferencesDao { fun getNavDrawerPinnedItems(user: JellyfinUser): List = getNavDrawerPinnedItems(user.rowId) - @Query("SELECT * from NavDrawerPinnedItem WHERE userId=:userId") + @Query("SELECT * from NavDrawerPinnedItem WHERE userId=:userId ORDER BY `order`") fun getNavDrawerPinnedItems(userId: Int): List @Insert(onConflict = OnConflictStrategy.REPLACE) 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 index f3f525859..2b5030236 100644 --- 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 @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.data.model +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey @@ -24,4 +25,5 @@ data class NavDrawerPinnedItem( val userId: Int, val itemId: String, val type: NavPinType, + @ColumnInfo(defaultValue = "-1") val order: Int, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index 1968d44b3..4af3d6021 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -66,7 +66,7 @@ class NavDrawerService }.launchIn(coroutineScope) } - private suspend fun updateNavDrawer( + suspend fun updateNavDrawer( user: JellyfinUser, userDto: UserDto, ) { @@ -108,6 +108,7 @@ class NavDrawerService val allItems = builtins + libraries val navDrawerPins = serverPreferencesDao.getNavDrawerPinnedItems(user) + Timber.d("navDrawerPins=%s", navDrawerPins) val filtered = allItems.groupBy { navDrawerPins.isPinned(it.id) } val items = filtered[true].orEmpty() val moreItems = filtered[false].orEmpty() 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 60a0bebc0..f1c436189 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 @@ -360,6 +360,28 @@ fun NavDrawer( ) } } + if (state.moreItems.isNotEmpty()) { + item { + val index = state.items.size + val interactionSource = remember { MutableInteractionSource() } + NavItem( + library = NavDrawerItem.More, + selected = selectedIndex == index, + moreExpanded = moreExpanded, + drawerOpen = isOpen, + interactionSource = interactionSource, + onClick = { + viewModel.onClickDrawerItem(index, NavDrawerItem.More) + }, + modifier = + Modifier + .ifElse( + selectedIndex == index, + Modifier.focusRequester(focusRequester), + ), + ) + } + } if (moreExpanded) { itemsIndexed(state.moreItems) { index, it -> val adjustedIndex = 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 dd7a8e799..8db0ee9d9 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 @@ -17,6 +17,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs @@ -48,6 +49,7 @@ class PreferencesViewModel private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, private val navDrawerItemRepository: NavDrawerItemRepository, + private val navDrawerService: NavDrawerService, private val serverPreferencesDao: ServerPreferencesDao, private val seerrServerRepository: SeerrServerRepository, private val deviceInfo: DeviceInfo, @@ -81,31 +83,25 @@ class PreferencesViewModel fun updatePins(newSelectedItems: List) { viewModelScope.launchIO(ExceptionHandler(true)) { serverRepository.currentUser.value?.let { user -> - val disabledItems = - mutableListOf().apply { - addAll(allNavDrawerItems) - removeAll(newSelectedItems) - } - val enabledItems = newSelectedItems.toSet() + val selectedIds = newSelectedItems.map { it.id }.toSet() val toSave = - disabledItems.map { + allNavDrawerItems.mapIndexed { index, item -> NavDrawerPinnedItem( user.rowId, - it.id, - NavPinType.UNPINNED, + item.id, + if (item.id in selectedIds) NavPinType.PINNED else NavPinType.UNPINNED, + index, ) - } + - 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) + serverRepository.currentUserDto.value?.let { userDto -> + if (user.id == userDto.id) { + navDrawerService.updateNavDrawer(user, userDto) + } + } } } } From 68e53bd00ba052960a975350726b31149abc4163 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 12 Feb 2026 15:01:54 -0500 Subject: [PATCH 3/3] Update preference --- .../wholphin/data/ServerPreferencesDao.kt | 2 +- .../wholphin/services/NavDrawerService.kt | 27 +- .../ui/preferences/NavDrawerPreference.kt | 245 ++++++++++++++++++ .../ui/preferences/PreferencesContent.kt | 21 +- .../ui/preferences/PreferencesViewModel.kt | 101 ++++++-- 5 files changed, 353 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt 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 index 38a4452f0..351fabfe1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerPreferencesDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerPreferencesDao.kt @@ -11,7 +11,7 @@ import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem interface ServerPreferencesDao { fun getNavDrawerPinnedItems(user: JellyfinUser): List = getNavDrawerPinnedItems(user.rowId) - @Query("SELECT * from NavDrawerPinnedItem WHERE userId=:userId ORDER BY `order`") + @Query("SELECT * from NavDrawerPinnedItem WHERE userId=:userId") fun getNavDrawerPinnedItems(userId: Int): List @Insert(onConflict = OnConflictStrategy.REPLACE) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index 4af3d6021..785eb6768 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -4,9 +4,9 @@ import android.content.Context import androidx.lifecycle.asFlow 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.BaseItem import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem @@ -107,11 +107,26 @@ class NavDrawerService } val allItems = builtins + libraries - val navDrawerPins = serverPreferencesDao.getNavDrawerPinnedItems(user) - Timber.d("navDrawerPins=%s", navDrawerPins) - val filtered = allItems.groupBy { navDrawerPins.isPinned(it.id) } - val items = filtered[true].orEmpty() - val moreItems = filtered[false].orEmpty() + val navDrawerPins = + serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + + val items = mutableListOf() + val moreItems = mutableListOf() + allItems + // Sort by order if non-default, existing items before customize will have -1 value + // New items from the server will get Int.MAX_VALUE + // Items the user doesn't have access to anymore will be skipped + .sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE } + .forEach { + // Assume pinned if unknown + val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED + if (pinned == NavPinType.PINNED) { + items.add(it) + } else { + moreItems.add(it) + } + } + _state.update { it.copy( items = items, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt new file mode 100644 index 000000000..0504d988f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt @@ -0,0 +1,245 @@ +package com.github.damontecres.wholphin.ui.preferences + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.foundation.interaction.MutableInteractionSource +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.nav.NavDrawerItem +import com.github.damontecres.wholphin.ui.theme.WholphinTheme + +data class NavDrawerPin( + val id: String, + val title: String, + val pinned: Boolean, + val item: NavDrawerItem, +) { + companion object { + fun create( + context: Context, + items: Map, + ) { + items.map { (item, pinned) -> + NavDrawerPin(item.id, item.name(context), pinned, item) + } + } + } +} + +enum class MoveDirection { + UP, + DOWN, +} + +private fun List.move( + direction: MoveDirection, + index: Int, +): List = + toMutableList().apply { + if (direction == MoveDirection.DOWN) { + val down = this[index] + val up = this[index + 1] + set(index, up) + set(index + 1, down) + } else { + val up = this[index] + val down = this[index - 1] + set(index - 1, up) + set(index, down) + } + } + +@Composable +fun NavDrawerPreference( + title: String, + summary: String?, + items: List, + onSave: (List) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + var showDialog by remember { mutableStateOf(false) } + ClickPreference( + title = title, + summary = summary, + onClick = { showDialog = true }, + interactionSource = interactionSource, + modifier = modifier, + ) + if (showDialog) { + NavDrawerPreferenceDialog( + items = items, + onDismissRequest = { showDialog = false }, + onClick = { index -> + val newItems = + items.toMutableList().apply { + set(index, items[index].let { it.copy(pinned = !it.pinned) }) + } + onSave.invoke(newItems) + }, + onMoveUp = { index -> + onSave(items.move(MoveDirection.UP, index)) + }, + onMoveDown = { index -> + onSave(items.move(MoveDirection.DOWN, index)) + }, + ) + } +} + +@Composable +fun NavDrawerPreferenceDialog( + items: List, + onDismissRequest: () -> Unit, + onClick: (Int) -> Unit, + onMoveUp: (Int) -> Unit, + onMoveDown: (Int) -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = stringResource(R.string.nav_drawer_pins), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp), + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + itemsIndexed(items, key = { _, item -> item.id }) { index, item -> + NavDrawerPreferenceListItem( + title = item.title, + pinned = item.pinned, + moveUpAllowed = index > 0, + moveDownAllowed = index < items.lastIndex, + onClick = { onClick.invoke(index) }, + onMoveUp = { onMoveUp.invoke(index) }, + onMoveDown = { onMoveDown.invoke(index) }, + modifier = Modifier, + ) + } + } + } + } +} + +@Composable +fun NavDrawerPreferenceListItem( + title: String, + pinned: Boolean, + moveUpAllowed: Boolean, + moveDownAllowed: Boolean, + onClick: () -> Unit, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 40.dp, max = 88.dp), + ) { + ListItem( + selected = false, + headlineContent = { + Text( + text = title, + ) + }, + trailingContent = { + Switch( + checked = pinned, + onCheckedChange = { + onClick.invoke() + }, + ) + }, + onClick = onClick, + modifier = Modifier.weight(1f), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.wrapContentWidth(), + ) { + MoveButton(R.string.fa_caret_up, moveUpAllowed, onMoveUp) + MoveButton(R.string.fa_caret_down, moveDownAllowed, onMoveDown) + } + } + } +} + +@Composable +private fun MoveButton( + @StringRes icon: Int, + enabled: Boolean, + onClick: () -> Unit, +) = Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(32.dp), +) { + Text( + text = stringResource(icon), + fontSize = 16.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) +} + +@PreviewTvSpec +@Composable +fun NavDrawerPreferenceListItemPreview() { + WholphinTheme { + NavDrawerPreferenceListItem( + title = "Movies", + pinned = true, + moveUpAllowed = true, + moveDownAllowed = true, + onClick = {}, + onMoveUp = {}, + onMoveDown = { }, + modifier = Modifier.width(360.dp), + ) + } +} 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 29fa873eb..8db91b08b 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 @@ -89,7 +89,7 @@ fun PreferencesContent( val currentServer by seerrVm.currentSeerrServer.collectAsState(null) var showPinFlow by remember { mutableStateOf(false) } - val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) + val navDrawerPins by viewModel.navDrawerPins.collectAsState(emptyList()) var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) var seerrDialogMode by remember { mutableStateOf(SeerrDialogMode.None) } @@ -335,21 +335,16 @@ fun PreferencesContent( } AppPreference.UserPinnedNavDrawerItems -> { - val selectedItems = - navDrawerPins.keys.mapNotNull { - if (navDrawerPins[it] ?: false) it else null - } - MultiChoicePreference( + NavDrawerPreference( title = stringResource(pref.title), summary = pref.summary(context, null), - possibleValues = navDrawerPins.keys, - selectedValues = selectedItems.toSet(), - onValueChange = { newSelectedItems -> - viewModel.updatePins(newSelectedItems) + items = navDrawerPins, + onSave = { + viewModel.updatePins(it) }, - ) { - Text(it.name(context)) - } + modifier = Modifier, + interactionSource = interactionSource, + ) } AppPreference.SendAppLogs -> { 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 8db0ee9d9..4113dbea2 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 @@ -2,14 +2,12 @@ package com.github.damontecres.wholphin.ui.preferences import android.content.Context import androidx.datastore.core.DataStore -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow 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.JellyfinUser import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavPinType @@ -23,18 +21,22 @@ import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.NavDrawerItem -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -57,7 +59,46 @@ class PreferencesViewModel ) : ViewModel(), RememberTabManager by rememberTabManager { private lateinit var allNavDrawerItems: List - val navDrawerPins = MutableLiveData>(mapOf()) +// val navDrawerPins = MutableLiveData>(emptyList()) + + val navDrawerPins = + navDrawerService.state + .combine( + serverRepository.currentUser.asFlow(), + ) { state, user -> + Pair(state, user) + }.combine(seerrServerRepository.active) { (state, user), seerr -> + Triple(state, user, seerr) + }.map { (state, user, seerr) -> + withContext(Dispatchers.IO) { + val navDrawerPins = + serverPreferencesDao + .getNavDrawerPinnedItems(user!!) + .associateBy { it.itemId } + + val allItems = state.let { it.items + it.moreItems } + val pins = + allItems + .sortedBy { + navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE + }.mapNotNull { + if (!seerr && it is NavDrawerItem.Discover) { + null + } else { + // Assume pinned if unknown + val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED + NavDrawerPin( + it.id, + it.name(context), + pinned == NavPinType.PINNED, + it, + ) + } + } + + pins + } + } val currentUser get() = serverRepository.currentUser @@ -72,34 +113,48 @@ class PreferencesViewModel init { viewModelScope.launchIO { serverRepository.currentUser.value?.let { user -> - allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems() - val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) - val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } - this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins) +// fetchNavDrawerPins(user) } } } - fun updatePins(newSelectedItems: List) { + private suspend fun fetchNavDrawerPins(user: JellyfinUser) { + navDrawerService.state.map { + val navDrawerPins = + serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + + val allItems = navDrawerService.state.first().let { it.items + it.moreItems } + val pins = + allItems + .sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE } + .map { + // Assume pinned if unknown + val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED + NavDrawerPin(it.id, it.name(context), pinned == NavPinType.PINNED, it) + } + pins + } + } + + fun updatePins(items: List) { viewModelScope.launchIO(ExceptionHandler(true)) { serverRepository.currentUser.value?.let { user -> - val selectedIds = newSelectedItems.map { it.id }.toSet() - val toSave = - allNavDrawerItems.mapIndexed { index, item -> - NavDrawerPinnedItem( - user.rowId, - item.id, - if (item.id in selectedIds) NavPinType.PINNED else NavPinType.UNPINNED, - index, - ) - } - serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) - val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) - val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } - this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins) serverRepository.currentUserDto.value?.let { userDto -> if (user.id == userDto.id) { + Timber.v("Updating pins") + val toSave = + items.mapIndexed { index, item -> + NavDrawerPinnedItem( + user.rowId, + item.id, + if (item.pinned) NavPinType.PINNED else NavPinType.UNPINNED, + index, + ) + } + serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) navDrawerService.updateNavDrawer(user, userDto) + } else { + throw IllegalStateException("User IDs do not match") } } }