Add ability to customize the navigation drawer menu (#60)

Adds support for customizing the items listed on the left side
navigation drawer menu. Each item can be toggled on/off in settings.

The hidden ones are still accessible under "More" on the nav drawer,
which only appears if some items are hidden.

Additionally, if any of menu items are hidden, their "latest" content
will not appear on the home page.

The setting is stored per user profile.
This commit is contained in:
damontecres 2025-10-23 15:57:26 -04:00 committed by GitHub
parent 27d0a17a00
commit fc4954b561
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 776 additions and 184 deletions

View file

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

View file

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

View file

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

View file

@ -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<NavDrawerItem> {
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<NavDrawerItem>): List<NavDrawerItem> {
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<NavDrawerPinnedItem>.isPinned(id: String) = (firstOrNull { it.itemId == id }?.type ?: NavPinType.PINNED) == NavPinType.PINNED

View file

@ -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<NavDrawerPinnedItem> = getNavDrawerPinnedItems(user.rowId)
@Query("SELECT * from NavDrawerPinnedItem WHERE userId=:userId")
fun getNavDrawerPinnedItems(userId: Int): List<NavDrawerPinnedItem>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun saveNavDrawerPinnedItems(vararg items: NavDrawerPinnedItem)
}

View file

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

View file

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

View file

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

View file

@ -526,6 +526,14 @@ sealed interface AppPreference<T> {
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 =

View file

@ -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>(LoadingState.Pending)
val homeRows = MutableLiveData<List<HomeRow>>()
@ -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<UUID>,
): List<HomeRow> {
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 =

View file

@ -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<NavDrawerItem>? = null
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
val libraries = MutableLiveData<List<NavDrawerItem>>(listOf())
val selectedIndex = MutableLiveData<Int>(-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,
)
}

View file

@ -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 <T> 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 <T> ComposablePreference(
list.addAll(value as List<Any>)
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<Any>,
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,
)
}

View file

@ -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 <T> MultiChoicePreference(
title: String,
summary: String?,
possibleValues: Set<T>,
selectedValues: Set<T>,
onValueChange: (List<T>) -> 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<T>()
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<DialogParams?>(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,
)
}
}
}

View file

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

View file

@ -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<AppPreferences>,
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<NavDrawerItem>
val navDrawerPins = MutableLiveData<Map<NavDrawerItem, Boolean>>(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<NavDrawerItem>) {
viewModelScope.launchIO(ExceptionHandler(true)) {
serverRepository.currentUser?.let { user ->
val disabledItems =
mutableListOf<NavDrawerItem>().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)
}
}
}
}

View file

@ -36,4 +36,6 @@
<string name="fa_list_ul" translatable="false">&#xf0ca;</string>
<string name="fa_file_video" translatable="false">&#xf1c8;</string>
<string name="fa_volume_low" translatable="false">&#xf027;</string>
<string name="fa_ellipsis" translatable="false">&#xf141;</string>
<string name="fa_ellipsis_vertical" translatable="false">&#xf142;</string>
</resources>

View file

@ -88,5 +88,8 @@
<string name="remove_favorite">Unfavorite</string>
<string name="add_favorite">Favorite</string>
<string name="favorites">Favorites</string>
<string name="nav_drawer_pins">Customize Navigation Drawer Items</string>
<string name="profile_specific_settings">User Profile Settings</string>
<string name="nav_drawer_pins_summary">Choose the default items to show, others will be hidden</string>
</resources>