mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Merge branch 'main' into fea/live-tv
This commit is contained in:
commit
aab70b7366
17 changed files with 776 additions and 184 deletions
|
|
@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,20 +8,26 @@ import androidx.room.TypeConverters
|
||||||
import androidx.room.migration.Migration
|
import androidx.room.migration.Migration
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
|
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
|
||||||
import org.jellyfin.sdk.model.serializer.toUUID
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class],
|
entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class],
|
||||||
version = 4,
|
version = 5,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
autoMigrations = [AutoMigration(3, 4)],
|
autoMigrations = [
|
||||||
|
AutoMigration(3, 4),
|
||||||
|
AutoMigration(4, 5),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun serverDao(): JellyfinServerDao
|
abstract fun serverDao(): JellyfinServerDao
|
||||||
|
|
||||||
abstract fun itemPlaybackDao(): ItemPlaybackDao
|
abstract fun itemPlaybackDao(): ItemPlaybackDao
|
||||||
|
|
||||||
|
abstract fun serverPreferencesDao(): ServerPreferencesDao
|
||||||
}
|
}
|
||||||
|
|
||||||
class Converters {
|
class Converters {
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ interface JellyfinServerDao {
|
||||||
fun updateUser(user: JellyfinUser): Int
|
fun updateUser(user: JellyfinUser): Int
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
fun addOrUpdateUser(user: JellyfinUser) {
|
fun addOrUpdateUser(user: JellyfinUser): JellyfinUser {
|
||||||
val result = addUser(user)
|
val result = addUser(user)
|
||||||
if (result == -1L) {
|
if (result == -1L) {
|
||||||
val toSave =
|
val toSave =
|
||||||
|
|
@ -92,7 +92,9 @@ interface JellyfinServerDao {
|
||||||
user
|
user
|
||||||
}
|
}
|
||||||
updateUser(toSave)
|
updateUser(toSave)
|
||||||
|
return toSave
|
||||||
}
|
}
|
||||||
|
return user.copy(rowId = result.toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId")
|
@Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -82,15 +82,13 @@ class ServerRepository
|
||||||
val sysInfo by apiClient.systemApi.getSystemInfo()
|
val sysInfo by apiClient.systemApi.getSystemInfo()
|
||||||
|
|
||||||
val updatedServer = server.copy(name = sysInfo.serverName)
|
val updatedServer = server.copy(name = sysInfo.serverName)
|
||||||
val updatedUser =
|
var updatedUser =
|
||||||
user.copy(
|
user.copy(
|
||||||
id = userDto.id,
|
id = userDto.id,
|
||||||
name = userDto.name,
|
name = userDto.name,
|
||||||
)
|
)
|
||||||
if (updatedUser.rowId <= 0) {
|
|
||||||
}
|
|
||||||
serverDao.addOrUpdateServer(updatedServer)
|
serverDao.addOrUpdateServer(updatedServer)
|
||||||
serverDao.addOrUpdateUser(updatedUser)
|
updatedUser = serverDao.addOrUpdateUser(updatedUser)
|
||||||
userPreferencesDataStore.updateData {
|
userPreferencesDataStore.updateData {
|
||||||
it
|
it
|
||||||
.toBuilder()
|
.toBuilder()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.AppDatabase
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||||
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||||
import com.github.damontecres.wholphin.data.Migrations
|
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.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
|
@ -46,6 +47,10 @@ object DatabaseModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao()
|
fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun userPreferencesDataStore(
|
fun userPreferencesDataStore(
|
||||||
|
|
|
||||||
|
|
@ -526,6 +526,14 @@ sealed interface AppPreference<T> {
|
||||||
getter = { },
|
getter = { },
|
||||||
setter = { prefs, _ -> prefs },
|
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,
|
AppPreference.SkipBackOnResume,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
PreferenceGroup(
|
||||||
|
title = R.string.profile_specific_settings,
|
||||||
|
preferences =
|
||||||
|
listOf(
|
||||||
|
AppPreference.UserPinnedNavDrawerItems,
|
||||||
|
),
|
||||||
|
),
|
||||||
PreferenceGroup(
|
PreferenceGroup(
|
||||||
title = R.string.about,
|
title = R.string.about,
|
||||||
preferences =
|
preferences =
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@ package com.github.damontecres.wholphin.ui.main
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.nav.NavigationManager
|
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.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||||
|
|
@ -19,7 +22,6 @@ import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
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.userLibraryApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
|
@ -37,6 +39,8 @@ class HomeViewModel
|
||||||
constructor(
|
constructor(
|
||||||
val api: ApiClient,
|
val api: ApiClient,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
|
val serverRepository: ServerRepository,
|
||||||
|
val navDrawerItemRepository: NavDrawerItemRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
|
||||||
val homeRows = MutableLiveData<List<HomeRow>>()
|
val homeRows = MutableLiveData<List<HomeRow>>()
|
||||||
|
|
@ -53,64 +57,44 @@ class HomeViewModel
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Timber.d("init 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
|
serverRepository.currentUserDto?.let { userDto ->
|
||||||
// .filter { it.key.startsWith("homesection") && it.value.isNotNullOrBlank() }
|
val includedIds =
|
||||||
// .sortedBy { it.key }
|
navDrawerItemRepository
|
||||||
// .map { HomeSection.fromKey(it.value ?: "") }
|
.getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
|
||||||
// .filter {
|
.filter { it is ServerNavDrawerItem }
|
||||||
// it in
|
.map { (it as ServerNavDrawerItem).itemId }
|
||||||
// setOf(
|
// TODO data is fetched all together which may be slow for large servers
|
||||||
// HomeSection.LATEST_MEDIA,
|
val resume = getResume(userDto.id, limit)
|
||||||
// HomeSection.NEXT_UP,
|
val nextUp = getNextUp(userDto.id, limit, prefs.enableRewatchingNextUp)
|
||||||
// HomeSection.RESUME,
|
val latest = getLatest(userDto, limit, includedIds)
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO data is fetched all together which may be slow for large servers
|
val homeRows =
|
||||||
val resume = getResume(user.id, limit)
|
if (prefs.combineContinueNext) {
|
||||||
val nextUp = getNextUp(user.id, limit, prefs.enableRewatchingNextUp)
|
listOf(
|
||||||
val latest = getLatest(user, limit)
|
HomeRow(
|
||||||
|
section = HomeSection.NEXT_UP,
|
||||||
val homeRows =
|
items = resume + nextUp,
|
||||||
if (prefs.combineContinueNext) {
|
),
|
||||||
listOf(
|
*latest.toTypedArray(),
|
||||||
HomeRow(
|
)
|
||||||
section = HomeSection.NEXT_UP,
|
} else {
|
||||||
items = resume + nextUp,
|
listOf(
|
||||||
),
|
HomeRow(
|
||||||
*latest.toTypedArray(),
|
section = HomeSection.RESUME,
|
||||||
)
|
items = resume,
|
||||||
} else {
|
),
|
||||||
listOf(
|
HomeRow(
|
||||||
HomeRow(
|
section = HomeSection.NEXT_UP,
|
||||||
section = HomeSection.RESUME,
|
items = nextUp,
|
||||||
items = resume,
|
),
|
||||||
),
|
*latest.toTypedArray(),
|
||||||
HomeRow(
|
)
|
||||||
section = HomeSection.NEXT_UP,
|
}
|
||||||
items = nextUp,
|
withContext(Dispatchers.Main) {
|
||||||
),
|
this@HomeViewModel.homeRows.value = homeRows
|
||||||
*latest.toTypedArray(),
|
loadingState.value = LoadingState.Success
|
||||||
)
|
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
this@HomeViewModel.homeRows.value = homeRows
|
|
||||||
loadingState.value = LoadingState.Success
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -163,11 +147,16 @@ class HomeViewModel
|
||||||
private suspend fun getLatest(
|
private suspend fun getLatest(
|
||||||
user: UserDto,
|
user: UserDto,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
includedIds: List<UUID>,
|
||||||
): List<HomeRow> {
|
): List<HomeRow> {
|
||||||
val latestMediaIncludes =
|
val latestMediaIncludes =
|
||||||
user.configuration?.orderedViews.orEmpty().toMutableList().apply {
|
user.configuration
|
||||||
removeAll(user.configuration?.latestItemsExcludes.orEmpty())
|
?.orderedViews
|
||||||
}
|
.orEmpty()
|
||||||
|
.toMutableList()
|
||||||
|
.apply {
|
||||||
|
removeAll(user.configuration?.latestItemsExcludes.orEmpty())
|
||||||
|
}.filter { includedIds.contains(it) }
|
||||||
|
|
||||||
val views by api.userViewsApi.getUserViews()
|
val views by api.userViewsApi.getUserViews()
|
||||||
val rows =
|
val rows =
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.ui.nav
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
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.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.AccountCircle
|
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.Home
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowLeft
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -49,6 +51,7 @@ import androidx.tv.material3.LocalContentColor
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.NavigationDrawer
|
import androidx.tv.material3.NavigationDrawer
|
||||||
import androidx.tv.material3.NavigationDrawerItem
|
import androidx.tv.material3.NavigationDrawerItem
|
||||||
|
import androidx.tv.material3.NavigationDrawerItemDefaults
|
||||||
import androidx.tv.material3.NavigationDrawerScope
|
import androidx.tv.material3.NavigationDrawerScope
|
||||||
import androidx.tv.material3.ProvideTextStyle
|
import androidx.tv.material3.ProvideTextStyle
|
||||||
import androidx.tv.material3.Text
|
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.R
|
||||||
import com.github.damontecres.wholphin.data.JellyfinServer
|
import com.github.damontecres.wholphin.data.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.JellyfinUser
|
import com.github.damontecres.wholphin.data.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
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.preferences.PreferenceScreenOption
|
||||||
import com.github.damontecres.wholphin.ui.spacedByWithFooter
|
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.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.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
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.CollectionType
|
||||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -84,45 +81,25 @@ import javax.inject.Inject
|
||||||
class NavDrawerViewModel
|
class NavDrawerViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
@param:ApplicationContext val context: Context,
|
private val navDrawerItemRepository: NavDrawerItemRepository,
|
||||||
val serverRepository: ServerRepository,
|
|
||||||
val api: ApiClient,
|
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
private var all: List<NavDrawerItem>? = null
|
||||||
|
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
|
||||||
val libraries = MutableLiveData<List<NavDrawerItem>>(listOf())
|
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) {
|
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) {
|
fun setIndex(index: Int) {
|
||||||
selectedIndex.value = index
|
selectedIndex.value = index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setShowMore(value: Boolean) {
|
||||||
|
showMore.value = value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class NavDrawerItem(
|
sealed interface NavDrawerItem {
|
||||||
val id: UUID,
|
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 name: String,
|
||||||
val destination: Destination,
|
val destination: Destination,
|
||||||
val type: CollectionType,
|
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
|
* Display the left side navigation drawer with [DestinationContent] on the right
|
||||||
|
|
@ -162,17 +166,49 @@ fun NavDrawer(
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val drawerFocusRequester = remember { FocusRequester() }
|
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
|
// 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)) {
|
BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) {
|
||||||
drawerState.setValue(DrawerValue.Open)
|
drawerState.setValue(DrawerValue.Open)
|
||||||
drawerFocusRequester.requestFocus()
|
drawerFocusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
|
val moreLibraries by viewModel.moreLibraries.observeAsState(listOf())
|
||||||
val libraries by viewModel.libraries.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
|
// A negative index is a built in page, >=0 is a library
|
||||||
val selectedIndex by viewModel.selectedIndex.observeAsState(-1)
|
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(
|
NavigationDrawer(
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -205,6 +241,7 @@ fun NavDrawer(
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.navigationManager.navigateTo(Destination.UserList)
|
viewModel.navigationManager.navigateTo(Destination.UserList)
|
||||||
},
|
},
|
||||||
|
modifier = Modifier.animateItem(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
|
|
@ -217,10 +254,11 @@ fun NavDrawer(
|
||||||
viewModel.navigationManager.navigateToFromDrawer(Destination.Search)
|
viewModel.navigationManager.navigateToFromDrawer(Destination.Search)
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.ifElse(
|
Modifier
|
||||||
selectedIndex == -2,
|
.ifElse(
|
||||||
Modifier.focusRequester(focusRequester),
|
selectedIndex == -2,
|
||||||
),
|
Modifier.focusRequester(focusRequester),
|
||||||
|
).animateItem(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
|
|
@ -237,27 +275,44 @@ fun NavDrawer(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.ifElse(
|
Modifier
|
||||||
selectedIndex == -1,
|
.ifElse(
|
||||||
Modifier.focusRequester(focusRequester),
|
selectedIndex == -1,
|
||||||
),
|
Modifier.focusRequester(focusRequester),
|
||||||
|
).animateItem(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
itemsIndexed(libraries) { index, it ->
|
itemsIndexed(libraries) { index, it ->
|
||||||
NavItem(
|
NavItem(
|
||||||
library = it,
|
library = it,
|
||||||
selected = selectedIndex == index,
|
selected = selectedIndex == index,
|
||||||
onClick = {
|
moreExpanded = showMore,
|
||||||
viewModel.setIndex(index)
|
onClick = { onClick.invoke(index, it) },
|
||||||
viewModel.navigationManager.navigateToFromDrawer(it.destination)
|
|
||||||
},
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.ifElse(
|
Modifier
|
||||||
selectedIndex == index,
|
.ifElse(
|
||||||
Modifier.focusRequester(focusRequester),
|
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 {
|
item {
|
||||||
IconNavItem(
|
IconNavItem(
|
||||||
text = "Settings",
|
text = "Settings",
|
||||||
|
|
@ -270,7 +325,7 @@ fun NavDrawer(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier,
|
modifier = Modifier.animateItem(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -339,20 +394,29 @@ fun NavigationDrawerScope.NavItem(
|
||||||
library: NavDrawerItem,
|
library: NavDrawerItem,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
|
moreExpanded: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
containerColor: Color = Color.Unspecified,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
val icon =
|
val icon =
|
||||||
when (library.type) {
|
when (library) {
|
||||||
CollectionType.MOVIES -> R.string.fa_film
|
NavDrawerItem.Favorites -> R.string.fa_heart
|
||||||
CollectionType.TVSHOWS -> R.string.fa_tv
|
NavDrawerItem.More -> R.string.fa_ellipsis
|
||||||
CollectionType.HOMEVIDEOS -> R.string.fa_video
|
|
||||||
CollectionType.LIVETV -> R.string.fa_tv
|
is ServerNavDrawerItem ->
|
||||||
CollectionType.MUSIC -> R.string.fa_music
|
when (library.type) {
|
||||||
CollectionType.BOXSETS -> R.string.fa_open_folder
|
CollectionType.MOVIES -> R.string.fa_film
|
||||||
CollectionType.PLAYLISTS -> R.string.fa_list_ul
|
CollectionType.TVSHOWS -> R.string.fa_tv
|
||||||
else -> library.iconStringRes
|
CollectionType.HOMEVIDEOS -> R.string.fa_video
|
||||||
} ?: R.string.fa_film
|
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 isFocused = interactionSource.collectIsFocusedAsState().value
|
||||||
val color =
|
val color =
|
||||||
when {
|
when {
|
||||||
|
|
@ -364,6 +428,10 @@ fun NavigationDrawerScope.NavItem(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
selected = false,
|
selected = false,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
colors =
|
||||||
|
NavigationDrawerItemDefaults.colors(
|
||||||
|
containerColor = containerColor,
|
||||||
|
),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
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,
|
interactionSource = null,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
text = library.name ?: library.id.toString(),
|
text = library.name(context),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.tv.material3.Button
|
import androidx.tv.material3.Button
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Switch
|
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.R
|
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.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.SortedSet
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -197,7 +197,7 @@ fun <T> ComposablePreference(
|
||||||
}
|
}
|
||||||
|
|
||||||
is AppMultiChoicePreference<*> -> {
|
is AppMultiChoicePreference<*> -> {
|
||||||
val values = stringArrayResource(preference.displayValues).toList()
|
val values = stringArrayResource(preference.displayValues).toSortedSet()
|
||||||
val summary =
|
val summary =
|
||||||
preference.summary?.let { stringResource(it) }
|
preference.summary?.let { stringResource(it) }
|
||||||
?: preference.summary(context, value)
|
?: preference.summary(context, value)
|
||||||
|
|
@ -207,46 +207,14 @@ fun <T> ComposablePreference(
|
||||||
list.addAll(value as List<Any>)
|
list.addAll(value as List<Any>)
|
||||||
list
|
list
|
||||||
}
|
}
|
||||||
|
MultiChoicePreference(
|
||||||
val onClick = { item: Any ->
|
possibleValues = values as SortedSet<Any>,
|
||||||
if (selectedValues.contains(item)) {
|
selectedValues = selectedValues,
|
||||||
selectedValues.remove(item)
|
|
||||||
} else {
|
|
||||||
selectedValues.add(item)
|
|
||||||
}
|
|
||||||
onValueChange.invoke(selectedValues.toList() as T)
|
|
||||||
}
|
|
||||||
|
|
||||||
ClickPreference(
|
|
||||||
title = title,
|
title = title,
|
||||||
summary = summary,
|
summary = summary,
|
||||||
onClick = {
|
onValueChange = {
|
||||||
dialogParams =
|
onValueChange.invoke(selectedValues.toList() as T)
|
||||||
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)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
interactionSource = interactionSource,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -69,6 +69,9 @@ fun PreferencesContent(
|
||||||
var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) }
|
var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) }
|
||||||
val state = rememberLazyListState()
|
val state = rememberLazyListState()
|
||||||
var preferences by remember { mutableStateOf(initialPreferences) }
|
var preferences by remember { mutableStateOf(initialPreferences) }
|
||||||
|
|
||||||
|
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.preferenceDataStore.data.collect {
|
viewModel.preferenceDataStore.data.collect {
|
||||||
preferences = it
|
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 -> {
|
else -> {
|
||||||
val value = pref.getter.invoke(preferences)
|
val value = pref.getter.invoke(preferences)
|
||||||
ComposablePreference(
|
ComposablePreference(
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
package com.github.damontecres.wholphin.ui.preferences
|
package com.github.damontecres.wholphin.ui.preferences
|
||||||
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
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.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.nav.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -15,5 +27,54 @@ class PreferencesViewModel
|
||||||
val preferenceDataStore: DataStore<AppPreferences>,
|
val preferenceDataStore: DataStore<AppPreferences>,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val rememberTabManager: RememberTabManager,
|
val rememberTabManager: RememberTabManager,
|
||||||
|
val serverRepository: ServerRepository,
|
||||||
|
val navDrawerItemRepository: NavDrawerItemRepository,
|
||||||
|
val serverPreferencesDao: ServerPreferencesDao,
|
||||||
) : ViewModel(),
|
) : 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,6 @@
|
||||||
<string name="fa_list_ul" translatable="false"></string>
|
<string name="fa_list_ul" translatable="false"></string>
|
||||||
<string name="fa_file_video" translatable="false"></string>
|
<string name="fa_file_video" translatable="false"></string>
|
||||||
<string name="fa_volume_low" translatable="false"></string>
|
<string name="fa_volume_low" translatable="false"></string>
|
||||||
|
<string name="fa_ellipsis" translatable="false"></string>
|
||||||
|
<string name="fa_ellipsis_vertical" translatable="false"></string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -88,5 +88,8 @@
|
||||||
<string name="remove_favorite">Unfavorite</string>
|
<string name="remove_favorite">Unfavorite</string>
|
||||||
<string name="add_favorite">Favorite</string>
|
<string name="add_favorite">Favorite</string>
|
||||||
<string name="favorites">Favorites</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>
|
</resources>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue