Store remembered tabs in app database instead of preferences (#1541)

## Description
Moves storing the remembered tabs from app preferences to the app
database. This means deleting the user will remove the remembered tabs
and the app preferences file is smaller and faster to load.

The remembered tabs are migrated automatically when the app is updated.

There are no significant user facing changes in this PR.

### Related issues
N/A

### Testing
Emulator

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-06-08 13:45:38 -04:00 • committed by GitHub
parent a4ee1e5175
commit b49926f80b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1379 additions and 599 deletions

View file

@ -0,0 +1,720 @@
{
"formatVersion": 1,
"database": {
"version": 34,
"identityHash": "278e485064fc9f184a2181599f0f8efb",
"entities": [
{
"tableName": "servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
},
{
"fieldPath": "lastUsed",
"columnName": "lastUsed",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT"
},
{
"fieldPath": "pin",
"columnName": "pin",
"affinity": "TEXT"
},
{
"fieldPath": "requireLogin",
"columnName": "requireLogin",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "lastUsed",
"columnName": "lastUsed",
"affinity": "TEXT"
},
{
"fieldPath": "uiLanguage",
"columnName": "uiLanguage",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_users_id_serverId",
"unique": true,
"columnNames": [
"id",
"serverId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)"
},
{
"name": "index_users_id",
"unique": false,
"columnNames": [
"id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)"
},
{
"name": "index_users_serverId",
"unique": false,
"columnNames": [
"serverId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)"
}
],
"foreignKeys": [
{
"table": "servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "ItemPlayback",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "rowId",
"columnName": "rowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sourceId",
"columnName": "sourceId",
"affinity": "TEXT"
},
{
"fieldPath": "audioIndex",
"columnName": "audioIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subtitleIndex",
"columnName": "subtitleIndex",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"rowId"
]
},
"indices": [
{
"name": "index_ItemPlayback_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "NavDrawerPinnedItem",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "LibraryDisplayInfo",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sort",
"columnName": "sort",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "direction",
"columnName": "direction",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "filter",
"columnName": "filter",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'{}'"
},
{
"fieldPath": "viewOptions",
"columnName": "viewOptions",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [
{
"name": "index_LibraryDisplayInfo_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)"
}
],
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "playback_effects",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
"fields": [
{
"fieldPath": "jellyfinUserRowId",
"columnName": "jellyfinUserRowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "videoFilter.rotation",
"columnName": "rotation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.brightness",
"columnName": "brightness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.contrast",
"columnName": "contrast",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.saturation",
"columnName": "saturation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.hue",
"columnName": "hue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.red",
"columnName": "red",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.green",
"columnName": "green",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.blue",
"columnName": "blue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.blur",
"columnName": "blur",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"jellyfinUserRowId",
"itemId",
"type"
]
}
},
{
"tableName": "PlaybackLanguageChoice",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "seriesId",
"columnName": "seriesId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT"
},
{
"fieldPath": "audioLanguage",
"columnName": "audioLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitleLanguage",
"columnName": "subtitleLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitlesDisabled",
"columnName": "subtitlesDisabled",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"seriesId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "ItemTrackModification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackIndex",
"columnName": "trackIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "delayMs",
"columnName": "delayMs",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId",
"trackIndex"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "seerr_servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_seerr_servers_url",
"unique": true,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
}
]
},
{
"tableName": "seerr_users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "jellyfinUserRowId",
"columnName": "jellyfinUserRowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authMethod",
"columnName": "authMethod",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT"
},
{
"fieldPath": "credential",
"columnName": "credential",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"jellyfinUserRowId",
"serverId"
]
},
"foreignKeys": [
{
"table": "seerr_servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
},
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"jellyfinUserRowId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "RememberedTab",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER 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": "index",
"columnName": "index",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId"
]
},
"indices": [
{
"name": "index_RememberedTab_userId_itemId",
"unique": true,
"columnNames": [
"userId",
"itemId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`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, '278e485064fc9f184a2181599f0f8efb')"
]
}
}

View file

@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.PlaybackEffect import com.github.damontecres.wholphin.data.model.PlaybackEffect
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.RememberedTab
import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.components.ViewOptions
@ -46,8 +47,9 @@ import java.util.UUID
ItemTrackModification::class, ItemTrackModification::class,
SeerrServer::class, SeerrServer::class,
SeerrUser::class, SeerrUser::class,
RememberedTab::class,
], ],
version = 33, version = 34,
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [
AutoMigration(3, 4), AutoMigration(3, 4),
@ -64,6 +66,7 @@ import java.util.UUID
AutoMigration(30, 31), AutoMigration(30, 31),
AutoMigration(31, 32), AutoMigration(31, 32),
AutoMigration(32, 33), AutoMigration(32, 33),
AutoMigration(33, 34),
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -81,6 +84,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun seerrServerDao(): SeerrServerDao abstract fun seerrServerDao(): SeerrServerDao
abstract fun playbackEffectDao(): PlaybackEffectDao abstract fun playbackEffectDao(): PlaybackEffectDao
abstract fun rememberedTabDao(): RememberedTabDao
} }
class Converters { class Converters {

View file

@ -0,0 +1,22 @@
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.RememberedTab
@Dao
interface RememberedTabDao {
@Query("SELECT * from RememberedTab WHERE userId=:userId AND itemId=:itemId")
suspend fun getRememberedTab(
userId: Int,
itemId: String,
): RememberedTab?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(item: RememberedTab): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveAll(items: List<RememberedTab>)
}

View file

@ -0,0 +1,24 @@
package com.github.damontecres.wholphin.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
foreignKeys = [
ForeignKey(
entity = JellyfinUser::class,
parentColumns = arrayOf("rowId"),
childColumns = arrayOf("userId"),
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
),
],
indices = [Index("userId", "itemId", unique = true)],
primaryKeys = ["userId", "itemId"],
)
data class RememberedTab(
val userId: Int,
val itemId: String,
val index: Int,
)

View file

@ -6,7 +6,11 @@ import android.os.Build
import androidx.core.content.edit import androidx.core.content.edit
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.RememberedTab
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.ScreensaverPreference
@ -28,9 +32,12 @@ import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
import com.github.damontecres.wholphin.util.Version import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -44,6 +51,8 @@ class AppUpgradeHandler
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val appPreferences: DataStore<AppPreferences>, private val appPreferences: DataStore<AppPreferences>,
private val seerrServerDao: SeerrServerDao, private val seerrServerDao: SeerrServerDao,
private val serverDao: JellyfinServerDao,
private val rememberedTabDao: RememberedTabDao,
) { ) {
val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0) val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0)
val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!) val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!)
@ -350,5 +359,55 @@ class AppUpgradeHandler
} }
} }
} }
if (previous.isEqualOrBefore(Version.fromString("1.0.1-2-g0"))) {
Timber.i("Starting tab migration")
val users = mutableMapOf<UUID, JellyfinUser?>()
val tabs =
appPreferences.data.first().interfacePreferences.rememberedTabsMap.mapNotNull { (key, tabIndex) ->
// "serverId_userId_itemId", itemId can have underscores
try {
val firstUnderScore = key.indexOf("_").takeIf { it >= 1 }
if (firstUnderScore != null) {
val secondUnderscore =
key
.indexOf("_", startIndex = firstUnderScore + 1)
.takeIf { it >= 1 }
if (secondUnderscore != null) {
val serverId = key.substring(0, firstUnderScore).toUUIDOrNull()
val userId =
key
.substring(firstUnderScore + 1, secondUnderscore)
.toUUIDOrNull()
val itemId = key.substring(secondUnderscore + 1, key.length)
if (userId != null && serverId != null) {
return@mapNotNull users
.getOrPut(userId) {
serverDao.getUser(serverId, userId)
}?.let {
RememberedTab(it.rowId, itemId, tabIndex)
}
}
}
}
} catch (ex: Exception) {
Timber.w(ex, "Error parsing '%s'", key)
}
null
}
Timber.v("Migrating %s tabs", tabs.size)
try {
if (tabs.isNotEmpty()) {
rememberedTabDao.saveAll(tabs)
}
appPreferences.updateData {
it.updateInterfacePreferences {
clearRememberedTabs()
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error saving migrated tabs")
}
}
} }
} }

View file

@ -0,0 +1,34 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.RememberedTab
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RememberedTabService
@Inject
constructor(
private val serverRepository: ServerRepository,
private val rememberedTabDao: RememberedTabDao,
) {
suspend fun getRememberedTab(itemId: String): Int? =
withContext(Dispatchers.IO) {
serverRepository.currentUser?.rowId?.let { userId ->
rememberedTabDao.getRememberedTab(userId, itemId)?.index
}
}
suspend fun saveRememberedTab(
itemId: String,
tabIndex: Int,
): Unit =
withContext(Dispatchers.IO) {
serverRepository.currentUser?.rowId?.let { userId ->
rememberedTabDao.save(RememberedTab(userId, itemId, tabIndex))
}
}
}

View file

@ -1,18 +1,12 @@
package com.github.damontecres.wholphin.services.hilt package com.github.damontecres.wholphin.services.hilt
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore
import androidx.work.WorkManager import androidx.work.WorkManager
import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -22,7 +16,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.android.androidDevice import org.jellyfin.sdk.android.androidDevice
@ -160,51 +153,6 @@ object AppModule {
@Singleton @Singleton
fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi() fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi()
/**
* Implementation of [RememberTabManager] which remembers by server, user, & item
*/
@Provides
@Singleton
fun rememberTabManager(
serverRepository: ServerRepository,
appPreference: DataStore<AppPreferences>,
@IoCoroutineScope scope: CoroutineScope,
) = object : RememberTabManager {
fun key(itemId: String): String =
serverRepository.current.value.let {
"${it?.server?.id}_${it?.user?.id}_$itemId"
}
override fun getRememberedTab(
preferences: UserPreferences,
itemId: String,
defaultTab: Int,
): Int {
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
return preferences.appPreferences.interfacePreferences
.getRememberedTabsOrDefault(key(itemId), defaultTab)
} else {
return defaultTab
}
}
override fun saveRememberedTab(
preferences: UserPreferences,
itemId: String,
tabIndex: Int,
) {
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
scope.launch(ExceptionHandler()) {
appPreference.updateData {
it.updateInterfacePreferences {
putRememberedTabs(key(itemId), tabIndex)
}
}
}
}
}
}
@Provides @Provides
@Singleton @Singleton
@IoDispatcher @IoDispatcher

View file

@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.PlaybackEffectDao
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
@ -74,6 +75,10 @@ object DatabaseModule {
@Singleton @Singleton
fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao()
@Provides
@Singleton
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
@Provides @Provides
@Singleton @Singleton
fun userPreferencesDataStore( fun userPreferencesDataStore(

View file

@ -7,7 +7,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@ -15,6 +18,9 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun CircularProgress(modifier: Modifier = Modifier) { fun CircularProgress(modifier: Modifier = Modifier) {
@ -58,3 +64,41 @@ fun LoadingPage(
) )
} }
} }
/**
* Fill the space and take focus, showing a loading indicator after the specified delay
*/
@Composable
fun DelayedLoadingPage(
modifier: Modifier = Modifier,
focusEnabled: Boolean = true,
delay: Duration = 300.milliseconds,
) {
val focusRequester = remember { FocusRequester() }
if (focusEnabled) {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
}
var show by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(delay)
show = true
}
Box(
contentAlignment = Alignment.Center,
modifier =
modifier
.fillMaxSize()
.focusRequester(focusRequester)
.focusable(focusEnabled),
) {
if (show) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier =
Modifier
.align(Alignment.Center)
.size(48.dp),
)
}
}
}

View file

@ -29,7 +29,6 @@ import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
@ -42,26 +41,26 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.StringStringProvider
import timber.log.Timber import timber.log.Timber
@Composable @Composable
fun TabRow( fun TabRow(
selectedTabIndex: Int, selectedTabIndex: Int,
tabs: List<String>, tabs: List<TabDetails>,
focusRequesters: List<FocusRequester>,
onClick: (Int) -> Unit, onClick: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val state = rememberLazyListState() val state = rememberLazyListState()
LaunchedEffect(selectedTabIndex) { LaunchedEffect(selectedTabIndex) {
if (selectedTabIndex >= 0) { if (selectedTabIndex in tabs.indices) {
state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt()) state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt())
} }
} }
var rowHasFocus by remember { mutableStateOf(false) } var rowHasFocus by remember { mutableStateOf(false) }
val currentSelectedTabIndex by rememberUpdatedState(selectedTabIndex) val currentSelectedTabIndex by rememberUpdatedState(selectedTabIndex)
val currentFocusRequesters by rememberUpdatedState(focusRequesters) // val currentFocusRequesters by rememberUpdatedState(focusRequesters)
val currentOnClick by rememberUpdatedState(onClick) val currentOnClick by rememberUpdatedState(onClick)
LazyRow( LazyRow(
@ -76,21 +75,21 @@ fun TabRow(
// If entering from left or right, use last or first tab // If entering from left or right, use last or first tab
// Otherwise use the selected tab // Otherwise use the selected tab
val index = currentSelectedTabIndex val index = currentSelectedTabIndex
val requesters = currentFocusRequesters // val requesters = currentFocusRequesters
Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$index") Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$index")
val focusRequester = val tab =
if (requestedFocusDirection == FocusDirection.Left) { if (requestedFocusDirection == FocusDirection.Left) {
requesters.lastOrNull() tabs.lastOrNull()
} else if (requestedFocusDirection == FocusDirection.Right) { } else if (requestedFocusDirection == FocusDirection.Right) {
requesters.firstOrNull() tabs.firstOrNull()
} else { } else {
requesters.getOrNull(index) tabs.getOrNull(index)
} }
(focusRequester ?: FocusRequester.Default).tryRequestFocus() (tab?.tabFocusRequester ?: FocusRequester.Default).tryRequestFocus()
} }
}, },
) { ) {
itemsIndexed(tabs) { index, tabTitle -> itemsIndexed(tabs) { index, tab ->
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val onTabClick = val onTabClick =
remember(index) { remember(index) {
@ -99,12 +98,12 @@ fun TabRow(
} }
} }
Tab( Tab(
title = tabTitle, title = tab.title.getString(),
selected = index == selectedTabIndex, selected = index == selectedTabIndex,
rowActive = rowHasFocus, rowActive = rowHasFocus,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onTabClick, onClick = onTabClick,
modifier = Modifier.focusRequester(focusRequesters.getOrElse(index) { FocusRequester() }), modifier = Modifier.focusRequester(tab.tabFocusRequester),
) )
} }
} }
@ -195,9 +194,11 @@ private fun TabRowPreview() {
WholphinTheme { WholphinTheme {
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
TabRow( TabRow(
selectedTabIndex = 1, selectedTabIndex = -1,
tabs = listOf("Tab 1", "Tab 2", "Tab 3"), tabs =
focusRequesters = listOf(), remember {
listOf("Tab 1", "Tab 2", "Tab 3").map { TabDetails(StringStringProvider(it)) }
},
onClick = {}, onClick = {},
) )
Tab( Tab(

View file

@ -0,0 +1,140 @@
package com.github.damontecres.wholphin.ui.components
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.RememberedTabService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.ui.util.StringProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import timber.log.Timber
@HiltViewModel(assistedFactory = TabViewModel.Factory::class)
class TabViewModel
@AssistedInject
constructor(
private val userPreferencesService: UserPreferencesService,
private val rememberedTabService: RememberedTabService,
private val backdropService: BackdropService,
@param:Assisted private val itemId: String,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(itemId: String): TabViewModel
}
private val _state = MutableStateFlow<Int>(UNSET)
val state: StateFlow<Int> = _state
init {
viewModelScope.launchIO {
val startingTab =
if (shouldRememberTabs()) {
Timber.v("Getting remembered tab for %s", itemId)
try {
rememberedTabService.getRememberedTab(itemId)
} catch (ex: Exception) {
Timber.e(ex, "Error getting tab for %s", itemId)
null
} ?: 0
} else {
0
}
_state.value = startingTab
}
}
private suspend fun shouldRememberTabs(): Boolean =
userPreferencesService
.getCurrent()
.appPreferences.interfacePreferences.rememberSelectedTab
fun updateSelectedTabIndex(tabIndex: Int) {
_state.value = tabIndex
viewModelScope.launchIO {
backdropService.clearBackdrop()
if (shouldRememberTabs()) {
Timber.v("Saving remembered tab for %s: %s", itemId, tabIndex)
rememberedTabService.saveRememberedTab(itemId, tabIndex)
}
}
}
}
data class TabDetails(
val title: StringProvider,
val tabFocusRequester: FocusRequester = FocusRequester(),
val contentFocusRequester: FocusRequester = FocusRequester(),
) {
constructor(
@StringRes stringResId: Int,
) : this(ResStringProvider(stringResId))
}
const val UNSET = -1234
/**
* A page showing multiple tabs
*
* This handles remembering the selected tabs via [TabViewModel] if needed
*/
@Composable
fun TabbedPage(
itemId: String,
tabs: List<TabDetails>,
modifier: Modifier = Modifier,
showTabs: Boolean = true,
viewModel: TabViewModel =
hiltViewModel<TabViewModel, TabViewModel.Factory>(
key = itemId,
creationCallback = { it.create(itemId) },
),
tabContent: @Composable (Int, TabDetails) -> Unit,
) {
val selectedTabIndex by viewModel.state.collectAsState()
Column(
modifier = modifier,
) {
AnimatedVisibility(
showTabs,
enter = expandVertically(),
exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier =
Modifier
.padding(vertical = 16.dp),
tabs = tabs,
onClick = viewModel::updateSelectedTabIndex,
)
}
selectedTabIndex.let { tabIndex ->
if (tabIndex >= 0) {
tabContent.invoke(tabIndex, tabs[tabIndex])
} else {
DelayedLoadingPage(focusEnabled = false)
}
}
}
}

View file

@ -1,25 +1,15 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -32,20 +22,23 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.CollectionFolderView
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TabDetails
import com.github.damontecres.wholphin.ui.components.TabbedPage
import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.components.ViewOptions
import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule
import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.StringStringProvider
import com.github.damontecres.wholphin.util.RememberTabManager import com.github.damontecres.wholphin.util.DataLoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.liveTvApi
import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -56,24 +49,35 @@ class LiveTvCollectionViewModel
val api: ApiClient, val api: ApiClient,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val rememberTabManager: RememberTabManager,
val backdropService: BackdropService, val backdropService: BackdropService,
) : ViewModel(), ) : ViewModel() {
RememberTabManager by rememberTabManager { val state = MutableStateFlow(LiveTvRecordingFolderState())
val recordingFolders = MutableStateFlow<List<TabId>>(emptyList())
init { init {
viewModelScope.launchIO { viewModelScope.launchIO {
val folders = try {
api.liveTvApi val folders =
.getRecordingFolders(userId = serverRepository.currentUser?.id) api.liveTvApi
.content.items .getRecordingFolders(userId = serverRepository.currentUser?.id)
.map { TabId(it.name ?: "Recordings", it.id) } .content.items
recordingFolders.value = folders .map { TabId(it.name ?: "Recordings", it.id) }
state.value =
LiveTvRecordingFolderState(DataLoadingState.Success(folders))
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error fetching recording folders")
state.value =
LiveTvRecordingFolderState(DataLoadingState.Error(ex))
}
} }
} }
} }
data class LiveTvRecordingFolderState(
val folders: DataLoadingState<List<TabId>> = DataLoadingState.Pending,
)
data class TabId( data class TabId(
val title: String, val title: String,
val id: UUID, val id: UUID,
@ -86,103 +90,91 @@ fun CollectionFolderLiveTv(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: LiveTvCollectionViewModel = hiltViewModel(), viewModel: LiveTvCollectionViewModel = hiltViewModel(),
) { ) {
val rememberedTabIndex = val state by viewModel.state.collectAsState()
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
val folders by viewModel.recordingFolders.collectAsState()
val tvGuideStr = stringResource(R.string.tv_guide) when (val st = state.folders) {
val tvDvrStr = stringResource(R.string.tv_dvr_schedule) is DataLoadingState.Error -> {
val tabs = ErrorMessage(st, modifier)
remember(folders) {
listOf(
TabId(tvGuideStr, UUID.randomUUID()),
TabId(tvDvrStr, UUID.randomUUID()),
) + folders
} }
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } DataLoadingState.Loading,
val focusRequester = remember { FocusRequester() } DataLoadingState.Pending,
val tabFocusRequesters = remember(tabs.size) { List(tabs.size) { FocusRequester() } } -> {
LoadingPage(modifier)
val firstTabFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
LaunchedEffect(selectedTabIndex) {
logTab("livetv", selectedTabIndex)
viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
viewModel.backdropService.clearBackdrop()
}
val onClickItem = { position: Int, item: BaseItem ->
viewModel.navigationManager.navigateTo(item.destination())
}
var showHeader by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
modifier = modifier,
) {
AnimatedVisibility(
showHeader,
enter = expandVertically(),
exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier =
Modifier
.padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester),
tabs = tabs.map { it.title },
onClick = { selectedTabIndex = it },
focusRequesters = tabFocusRequesters,
)
} }
when (selectedTabIndex) {
0 -> {
TvGuideGrid(
preferences = preferences,
onRowPosition = {
showHeader = it <= 0
},
Modifier
.fillMaxSize()
.focusRequester(focusRequester),
)
}
1 -> { is DataLoadingState.Success<List<TabId>> -> {
DvrSchedule( val tabs =
requestFocusAfterLoading = true, remember {
focusRequesterOnEmpty = tabFocusRequesters[1], listOf(
modifier = TabDetails(R.string.tv_guide),
Modifier TabDetails(R.string.tv_dvr_schedule),
.fillMaxSize() ) +
.focusRequester(focusRequester), st.data.map {
) TabDetails(StringStringProvider(it.title))
} }
}
val onClickItem =
remember {
{ position: Int, item: BaseItem ->
viewModel.navigationManager.navigateTo(item.destination())
}
}
else -> { var showHeader by rememberSaveable { mutableStateOf(true) }
val folderIndex = selectedTabIndex - 2 TabbedPage(
if (folderIndex in folders.indices) { itemId = destination.itemId.toString(),
CollectionFolderView( tabs = tabs,
preferences = preferences, modifier = modifier,
onClickItem = onClickItem, showTabs = showHeader,
itemId = folders[folderIndex].id, ) { tabIndex, tabDetails ->
initialFilter = CollectionFolderFilter(), when (tabIndex) {
showTitle = false, 0 -> {
recursive = false, TvGuideGrid(
sortOptions = VideoSortOptions, preferences = preferences,
modifier = Modifier, onRowPosition = {
positionCallback = { columns, position -> showHeader = it <= 0
showHeader = position < columns },
}, Modifier
playEnabled = false, .fillMaxSize()
defaultViewOptions = ViewOptions(), .focusRequester(tabDetails.contentFocusRequester),
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), )
) }
} else {
ErrorMessage("Invalid tab index $selectedTabIndex", null) 1 -> {
DvrSchedule(
requestFocusAfterLoading = true,
focusRequesterOnEmpty = tabDetails.tabFocusRequester,
modifier =
Modifier
.fillMaxSize()
.focusRequester(tabDetails.contentFocusRequester),
)
}
else -> {
val folderIndex = tabIndex - 2
if (folderIndex in st.data.indices) {
CollectionFolderView(
preferences = preferences,
onClickItem = onClickItem,
itemId = st.data[folderIndex].id,
initialFilter = CollectionFolderFilter(),
showTitle = false,
recursive = false,
sortOptions = VideoSortOptions,
modifier = Modifier.focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
playEnabled = false,
defaultViewOptions = ViewOptions(),
focusRequesterOnEmpty = tabDetails.tabFocusRequester,
)
} else {
ErrorMessage("Invalid tab index $tabIndex", null)
}
}
} }
} }
} }

View file

@ -1,24 +1,14 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
@ -28,11 +18,11 @@ import com.github.damontecres.wholphin.ui.components.CollectionFolderView
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedMovie import com.github.damontecres.wholphin.ui.components.RecommendedMovie
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabDetails
import com.github.damontecres.wholphin.ui.components.TabbedPage
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@ -45,51 +35,25 @@ fun CollectionFolderMovie(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
preferencesViewModel: PreferencesViewModel = hiltViewModel(), preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) { ) {
val rememberedTabIndex =
remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) }
val tabs = val tabs =
listOf( remember {
stringResource(R.string.recommended), listOf(
stringResource(R.string.library), TabDetails(R.string.recommended),
stringResource(R.string.collections), TabDetails(R.string.library),
stringResource(R.string.genres), TabDetails(R.string.collections),
) TabDetails(R.string.genres),
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } )
val focusRequester = remember { FocusRequester() } }
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
val firstTabFocusRequester = remember { FocusRequester() }
// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
LaunchedEffect(selectedTabIndex) {
logTab("movie", selectedTabIndex)
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
preferencesViewModel.backdropService.clearBackdrop()
}
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
Column( TabbedPage(
itemId = destination.itemId.toString(),
tabs = tabs,
modifier = modifier, modifier = modifier,
) { showTabs = showHeader,
AnimatedVisibility( ) { tabIndex, tabDetails ->
showHeader, when (tabIndex) {
enter = expandVertically(),
exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier =
Modifier
.padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester),
tabs = tabs,
onClick = { selectedTabIndex = it },
focusRequesters = tabFocusRequesters,
)
}
when (selectedTabIndex) {
// Recommended // Recommended
0 -> { 0 -> {
RecommendedMovie( RecommendedMovie(
@ -101,7 +65,7 @@ fun CollectionFolderMovie(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
) )
} }
@ -128,12 +92,12 @@ fun CollectionFolderMovie(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = true, playEnabled = true,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), focusRequesterOnEmpty = tabDetails.tabFocusRequester,
) )
} }
@ -160,12 +124,12 @@ fun CollectionFolderMovie(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false, playEnabled = false,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), focusRequesterOnEmpty = tabDetails.tabFocusRequester,
) )
} }
@ -178,12 +142,12 @@ fun CollectionFolderMovie(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
) )
} }
else -> { else -> {
ErrorMessage("Invalid tab index $selectedTabIndex", null) ErrorMessage("Invalid tab index $tabIndex", null)
} }
} }
} }

View file

@ -1,24 +1,14 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -36,17 +26,16 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.GridClickActions import com.github.damontecres.wholphin.ui.components.GridClickActions
import com.github.damontecres.wholphin.ui.components.RecommendedMusic import com.github.damontecres.wholphin.ui.components.RecommendedMusic
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabDetails
import com.github.damontecres.wholphin.ui.components.TabbedPage
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
import com.github.damontecres.wholphin.ui.data.AlbumSortOptions import com.github.damontecres.wholphin.ui.data.AlbumSortOptions
import com.github.damontecres.wholphin.ui.data.ArtistSortOptions import com.github.damontecres.wholphin.ui.data.ArtistSortOptions
import com.github.damontecres.wholphin.ui.data.SongSortOptions import com.github.damontecres.wholphin.ui.data.SongSortOptions
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -66,10 +55,8 @@ class CollectionFolderMusicViewModel
private val musicService: MusicService, private val musicService: MusicService,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
val backdropService: BackdropService, val backdropService: BackdropService,
private val rememberTabManager: RememberTabManager,
@Assisted private val itemId: UUID, @Assisted private val itemId: UUID,
) : ViewModel(), ) : ViewModel() {
RememberTabManager by rememberTabManager {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(itemId: UUID): CollectionFolderMusicViewModel fun create(itemId: UUID): CollectionFolderMusicViewModel
@ -132,30 +119,16 @@ fun CollectionFolderMusic(
creationCallback = { it.create(destination.itemId) }, creationCallback = { it.create(destination.itemId) },
), ),
) { ) {
val rememberedTabIndex =
remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) }
val tabs = val tabs =
listOf( remember {
stringResource(R.string.recommended), listOf(
stringResource(R.string.albums), TabDetails(R.string.recommended),
stringResource(R.string.artists), TabDetails(R.string.albums),
stringResource(R.string.genres), TabDetails(R.string.artists),
stringResource(R.string.songs), TabDetails(R.string.genres),
) TabDetails(R.string.songs),
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } )
val focusRequester = remember { FocusRequester() } }
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
val firstTabFocusRequester = remember { FocusRequester() }
// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
LaunchedEffect(selectedTabIndex) {
logTab("music", selectedTabIndex)
viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
viewModel.backdropService.clearBackdrop()
}
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
val actions = val actions =
@ -167,27 +140,13 @@ fun CollectionFolderMusic(
onClickPlayRemoteButton = viewModel::onClickPlayRemoteButton, onClickPlayRemoteButton = viewModel::onClickPlayRemoteButton,
) )
} }
TabbedPage(
Column( itemId = destination.itemId.toString(),
tabs = tabs,
modifier = modifier, modifier = modifier,
) { showTabs = showHeader,
AnimatedVisibility( ) { tabIndex, tabDetails ->
showHeader, when (tabIndex) {
enter = expandVertically(),
exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier =
Modifier
.padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester),
tabs = tabs,
onClick = { selectedTabIndex = it },
focusRequesters = tabFocusRequesters,
)
}
when (selectedTabIndex) {
// Recommended // Recommended
0 -> { 0 -> {
RecommendedMusic( RecommendedMusic(
@ -199,7 +158,7 @@ fun CollectionFolderMusic(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
) )
} }
@ -224,12 +183,12 @@ fun CollectionFolderMusic(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = true, playEnabled = true,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), focusRequesterOnEmpty = tabDetails.tabFocusRequester,
) )
} }
@ -254,12 +213,12 @@ fun CollectionFolderMusic(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false, playEnabled = false,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), focusRequesterOnEmpty = tabDetails.tabFocusRequester,
) )
} }
@ -272,7 +231,7 @@ fun CollectionFolderMusic(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
) )
} }
@ -297,17 +256,17 @@ fun CollectionFolderMusic(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = true, playEnabled = true,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), focusRequesterOnEmpty = tabDetails.tabFocusRequester,
) )
} }
else -> { else -> {
ErrorMessage("Invalid tab index $selectedTabIndex", null) ErrorMessage("Invalid tab index $tabIndex", null)
} }
} }
} }

View file

@ -1,102 +1,77 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.filter.DefaultTvFilterOptions import com.github.damontecres.wholphin.data.filter.DefaultTvFilterOptions
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.CollectionFolderView
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedTvShow import com.github.damontecres.wholphin.ui.components.RecommendedTvShow
import com.github.damontecres.wholphin.ui.components.StudioCardGrid import com.github.damontecres.wholphin.ui.components.StudioCardGrid
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabDetails
import com.github.damontecres.wholphin.ui.components.TabbedPage
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.data.SeriesSortOptions import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import javax.inject.Inject
@HiltViewModel
class CollectionFolderTvViewModel
@Inject
constructor(
val navigationManager: NavigationManager,
) : ViewModel()
@Composable @Composable
fun CollectionFolderTv( fun CollectionFolderTv(
preferences: UserPreferences, preferences: UserPreferences,
destination: Destination.MediaItem, destination: Destination.MediaItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
preferencesViewModel: PreferencesViewModel = hiltViewModel(), viewModel: CollectionFolderTvViewModel = hiltViewModel(),
) { ) {
val rememberedTabIndex =
remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) }
val tabs = val tabs =
listOf( remember {
stringResource(R.string.recommended), listOf(
stringResource(R.string.library), TabDetails(R.string.recommended),
stringResource(R.string.genres), TabDetails(R.string.library),
stringResource(R.string.studios), TabDetails(R.string.genres),
) TabDetails(R.string.studios),
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } )
val focusRequester = remember { FocusRequester() } }
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } val onClickItem =
remember {
val firstTabFocusRequester = remember { FocusRequester() } { position: Int, item: BaseItem ->
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } viewModel.navigationManager.navigateTo(item.destination())
}
LaunchedEffect(selectedTabIndex) { }
logTab("tv", selectedTabIndex)
preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex)
preferencesViewModel.backdropService.clearBackdrop()
}
val onClickItem = { item: BaseItem ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
}
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
Column( TabbedPage(
itemId = destination.itemId.toString(),
tabs = tabs,
modifier = modifier, modifier = modifier,
) { showTabs = showHeader,
AnimatedVisibility( ) { tabIndex, tabDetails ->
showHeader, when (tabIndex) {
enter = expandVertically(),
exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier =
Modifier
.padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester),
tabs = tabs,
onClick = { selectedTabIndex = it },
focusRequesters = tabFocusRequesters,
)
}
when (selectedTabIndex) {
// Recommended // Recommended
0 -> { 0 -> {
RecommendedTvShow( RecommendedTvShow(
@ -108,7 +83,7 @@ fun CollectionFolderTv(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
) )
} }
@ -132,15 +107,13 @@ fun CollectionFolderTv(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
onClickItem = { _, item -> onClickItem = onClickItem,
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
playEnabled = false, playEnabled = false,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), focusRequesterOnEmpty = tabDetails.tabFocusRequester,
) )
} }
@ -153,7 +126,7 @@ fun CollectionFolderTv(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
) )
} }
@ -165,12 +138,12 @@ fun CollectionFolderTv(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
) )
} }
else -> { else -> {
ErrorMessage("Invalid tab index $selectedTabIndex", null) ErrorMessage("Invalid tab index $tabIndex", null)
} }
} }
} }

View file

@ -1,36 +1,30 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.filter.DefaultForFavoritesFilterOptions import com.github.damontecres.wholphin.data.filter.DefaultForFavoritesFilterOptions
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.CollectionFolderView
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GridClickActions import com.github.damontecres.wholphin.ui.components.GridClickActions
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabDetails
import com.github.damontecres.wholphin.ui.components.TabbedPage
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
@ -39,86 +33,55 @@ import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.data.SeriesSortOptions import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import javax.inject.Inject
@HiltViewModel
class FavoritesViewModel
@Inject
constructor(
val navigationManager: NavigationManager,
) : ViewModel()
@Composable @Composable
fun FavoritesPage( fun FavoritesPage(
preferences: UserPreferences, preferences: UserPreferences,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
preferencesViewModel: PreferencesViewModel = hiltViewModel(), viewModel: FavoritesViewModel = hiltViewModel(),
) { ) {
val uiPrefs = preferences.appPreferences.interfacePreferences val tabs =
val rememberedTabIndex =
remember { remember {
preferencesViewModel.getRememberedTab( listOf(
preferences, TabDetails(R.string.movies),
NavDrawerItem.Favorites.id, TabDetails(R.string.tv_shows),
0, TabDetails(R.string.episodes),
TabDetails(R.string.videos),
TabDetails(R.string.playlists),
TabDetails(R.string.people),
)
}
val actions =
remember {
GridClickActions(
onClickItem = { _, item -> viewModel.navigationManager.navigateTo(item.destination()) },
) )
} }
val tabs =
listOf(
stringResource(R.string.movies),
stringResource(R.string.tv_shows),
stringResource(R.string.episodes),
stringResource(R.string.videos),
stringResource(R.string.playlists),
stringResource(R.string.people),
)
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(selectedTabIndex) {
logTab("favorites", selectedTabIndex)
preferencesViewModel.saveRememberedTab(
preferences,
NavDrawerItem.Favorites.id,
selectedTabIndex,
)
preferencesViewModel.backdropService.clearBackdrop()
}
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
val onClickItem = { item: BaseItem -> // LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
preferencesViewModel.navigationManager.navigateTo(item.destination()) TabbedPage(
} itemId = NavDrawerItem.Favorites.id,
tabs = tabs,
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } showTabs = showHeader,
Column(
modifier = modifier, modifier = modifier,
) { ) { tabIndex, tabDetails ->
AnimatedVisibility(
showHeader,
enter = expandVertically(),
exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier =
Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp),
tabs = tabs,
onClick = { selectedTabIndex = it },
focusRequesters = tabFocusRequesters,
)
}
val actions =
remember {
GridClickActions(
onClickItem = { _, item -> onClickItem.invoke(item) },
)
}
// TODO playEnabled = true for movies & episodes // TODO playEnabled = true for movies & episodes
when (selectedTabIndex) { when (tabIndex) {
// Movies // Movies
0 -> { 0 -> {
CollectionFolderView( CollectionFolderView(
@ -141,13 +104,13 @@ fun FavoritesPage(
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false, playEnabled = false,
filterOptions = DefaultForFavoritesFilterOptions, filterOptions = DefaultForFavoritesFilterOptions,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), focusRequesterOnEmpty = tabDetails.tabFocusRequester,
) )
} }
@ -173,13 +136,13 @@ fun FavoritesPage(
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false, playEnabled = false,
filterOptions = DefaultForFavoritesFilterOptions, filterOptions = DefaultForFavoritesFilterOptions,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), focusRequesterOnEmpty = tabDetails.tabFocusRequester,
) )
} }
@ -206,13 +169,13 @@ fun FavoritesPage(
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false, playEnabled = false,
filterOptions = DefaultForFavoritesFilterOptions, filterOptions = DefaultForFavoritesFilterOptions,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), focusRequesterOnEmpty = tabDetails.tabFocusRequester,
) )
} }
@ -238,13 +201,13 @@ fun FavoritesPage(
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false, playEnabled = false,
filterOptions = DefaultForFavoritesFilterOptions, filterOptions = DefaultForFavoritesFilterOptions,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), focusRequesterOnEmpty = tabDetails.tabFocusRequester,
) )
} }
@ -270,13 +233,13 @@ fun FavoritesPage(
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false, playEnabled = false,
filterOptions = DefaultForFavoritesFilterOptions, filterOptions = DefaultForFavoritesFilterOptions,
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), focusRequesterOnEmpty = tabDetails.tabFocusRequester,
) )
} }
@ -307,18 +270,18 @@ fun FavoritesPage(
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false, playEnabled = false,
filterOptions = listOf(), filterOptions = listOf(),
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), focusRequesterOnEmpty = tabDetails.tabFocusRequester,
) )
} }
else -> { else -> {
ErrorMessage("Invalid tab index $selectedTabIndex", null) ErrorMessage("Invalid tab index $tabIndex", null)
} }
} }
} }

View file

@ -57,6 +57,7 @@ import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TabDetails
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
@ -64,6 +65,7 @@ import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.StringStringProvider
import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
@ -120,13 +122,21 @@ fun SeriesOverviewContent(
var requestFocusAfterSeason by remember { mutableStateOf(false) } var requestFocusAfterSeason by remember { mutableStateOf(false) }
val seasonStr = stringResource(R.string.tv_season) val seasonStr = stringResource(R.string.tv_season)
val tabFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } }
val contentFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } }
val tabs = val tabs =
seasons.map { season -> seasons.mapIndexed { index, season ->
season?.name TabDetails(
?: season?.data?.indexNumber?.let { "$seasonStr $it" } title =
?: "" StringStringProvider(
season?.name
?: season?.data?.indexNumber?.let { "$seasonStr $it" }
?: "",
),
tabFocusRequester = tabFocusRequesters[index],
contentFocusRequester = contentFocusRequesters[index],
)
} }
val focusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } }
val currentOnChangeSeason by rememberUpdatedState(onChangeSeason) val currentOnChangeSeason by rememberUpdatedState(onChangeSeason)
@ -172,7 +182,6 @@ fun SeriesOverviewContent(
requestFocusAfterSeason = true requestFocusAfterSeason = true
} }
}, },
focusRequesters = focusRequesters,
modifier = modifier =
Modifier Modifier
.focusRequester(tabRowFocusRequester) .focusRequester(tabRowFocusRequester)

View file

@ -1,111 +1,78 @@
package com.github.damontecres.wholphin.ui.discover package com.github.damontecres.wholphin.ui.discover
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabDetails
import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.components.TabbedPage
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
@Composable @Composable
fun DiscoverPage( fun DiscoverPage(
preferences: UserPreferences, preferences: UserPreferences,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) { ) {
val rememberedTabIndex =
remember { preferencesViewModel.getRememberedTab(preferences, NavDrawerItem.Discover.id, 0) }
val tabs = val tabs =
listOf( remember {
stringResource(R.string.discover), listOf(
stringResource(R.string.request), TabDetails(R.string.discover),
stringResource(R.string.search), TabDetails(R.string.request),
) TabDetails(R.string.search),
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val tabFocusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } }
LaunchedEffect(selectedTabIndex) {
logTab("discover", selectedTabIndex)
preferencesViewModel.saveRememberedTab(preferences, NavDrawerItem.Discover.id, selectedTabIndex)
}
OneTimeLaunchedEffect { preferencesViewModel.backdropService.clearBackdrop() }
var showHeader by rememberSaveable { mutableStateOf(true) }
Column(
modifier = modifier,
) {
AnimatedVisibility(
showHeader,
enter = expandVertically(),
exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTabIndex,
modifier =
Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp),
tabs = tabs,
onClick = { selectedTabIndex = it },
focusRequesters = tabFocusRequesters,
) )
} }
when (selectedTabIndex) { var showHeader by rememberSaveable { mutableStateOf(true) }
TabbedPage(
itemId = NavDrawerItem.Discover.id,
tabs = tabs,
modifier = modifier,
showTabs = showHeader,
) { tabIndex, tabDetails ->
when (tabIndex) {
// Discover // Discover
0 -> { 0 -> {
SeerrDiscoverPage( SeerrDiscoverPage(
preferences = preferences, preferences = preferences,
modifier = modifier =
Modifier Modifier
.fillMaxSize(), .fillMaxSize()
.focusRequester(tabDetails.contentFocusRequester),
) )
} }
// Requests // Requests
1 -> { 1 -> {
SeerrRequestsPage( SeerrRequestsPage(
focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), focusRequesterOnEmpty = tabDetails.tabFocusRequester,
modifier = modifier =
Modifier Modifier
.fillMaxSize(), .fillMaxSize()
.focusRequester(tabDetails.contentFocusRequester),
) )
} }
// Search // Search
2 -> { 2 -> {
LaunchedEffect(Unit) {
preferencesViewModel.backdropService.clearBackdrop()
}
DiscoverSearchPage( DiscoverSearchPage(
preferences = preferences, preferences = preferences,
modifier = Modifier.fillMaxSize(), modifier =
Modifier
.fillMaxSize()
.focusRequester(tabDetails.contentFocusRequester),
) )
} }
else -> { else -> {
ErrorMessage("Invalid tab index $selectedTabIndex", null) ErrorMessage("Invalid tab index $tabIndex", null)
} }
} }
} }

View file

@ -86,6 +86,7 @@ import com.github.damontecres.wholphin.ui.cards.ItemRowTitle
import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.SearchEditTextBox import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
import com.github.damontecres.wholphin.ui.components.TabDetails
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.components.VoiceInputManager import com.github.damontecres.wholphin.ui.components.VoiceInputManager
import com.github.damontecres.wholphin.ui.components.VoiceSearchButton import com.github.damontecres.wholphin.ui.components.VoiceSearchButton
@ -518,6 +519,15 @@ fun SearchPage(
} }
} }
} }
val tabs =
remember {
listOf(
TabDetails(R.string.library),
TabDetails(R.string.discover),
)
}
Column( Column(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
@ -610,12 +620,7 @@ fun SearchPage(
) { ) {
TabRow( TabRow(
selectedTabIndex = selectedTab, selectedTabIndex = selectedTab,
tabs = tabs = tabs,
listOf(
stringResource(R.string.library),
stringResource(R.string.discover),
),
focusRequesters = tabFocusRequesters,
onClick = { onClick = {
selectedTab = it selectedTab = it
val row = val row =

View file

@ -359,6 +359,7 @@ fun DestinationContent(
} }
Destination.Discover -> { Destination.Discover -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
DiscoverPage( DiscoverPage(
preferences = preferences, preferences = preferences,
modifier = modifier, modifier = modifier,

View file

@ -28,7 +28,6 @@ import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -52,14 +51,12 @@ class PreferencesViewModel
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val backdropService: BackdropService, val backdropService: BackdropService,
val screensaverService: ScreensaverService, val screensaverService: ScreensaverService,
private val rememberTabManager: RememberTabManager,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val seerrServerRepository: SeerrServerRepository, private val seerrServerRepository: SeerrServerRepository,
private val deviceInfo: DeviceInfo, private val deviceInfo: DeviceInfo,
private val clientInfo: ClientInfo, private val clientInfo: ClientInfo,
private val updateChecker: UpdateChecker, private val updateChecker: UpdateChecker,
) : ViewModel(), ) : ViewModel() {
RememberTabManager by rememberTabManager {
val currentUser val currentUser
get() = get() =
serverRepository.currentUserFlow.stateIn( serverRepository.currentUserFlow.stateIn(

View file

@ -1,45 +0,0 @@
package com.github.damontecres.wholphin.util
import com.github.damontecres.wholphin.preferences.UserPreferences
import java.util.UUID
/**
* Functions to remember tabs choices for a library
*/
interface RememberTabManager {
/**
* If enabled, get the remembered tab index for the given item
*/
fun getRememberedTab(
preferences: UserPreferences,
itemId: UUID,
defaultTab: Int,
): Int = getRememberedTab(preferences, itemId.toString(), defaultTab)
/**
* If enabled, get the remembered tab index for the given item
*/
fun getRememberedTab(
preferences: UserPreferences,
itemId: String,
defaultTab: Int,
): Int
/**
* If enabled, save the remembered tab index for the given item
*/
fun saveRememberedTab(
preferences: UserPreferences,
itemId: UUID,
tabIndex: Int,
) = saveRememberedTab(preferences, itemId.toString(), tabIndex)
/**
* If enabled, save the remembered tab index for the given item
*/
fun saveRememberedTab(
preferences: UserPreferences,
itemId: String,
tabIndex: Int,
)
}

View file

@ -180,7 +180,7 @@ enum DisplayToggle{
message InterfacePreferences { message InterfacePreferences {
ThemeSongVolume play_theme_songs = 1; ThemeSongVolume play_theme_songs = 1;
bool remember_selected_tab = 2; bool remember_selected_tab = 2;
map<string, int32> remembered_tabs = 3; map<string, int32> remembered_tabs = 3 [deprecated = true];
AppThemeColors app_theme_colors = 4; AppThemeColors app_theme_colors = 4;
bool nav_drawer_switch_on_focus = 5; bool nav_drawer_switch_on_focus = 5;
bool show_clock = 6; bool show_clock = 6;

View file

@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.PlaybackEffectDao
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
@ -35,7 +36,6 @@ import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -166,17 +166,6 @@ object TestModule {
return api return api
} }
/**
* Implementation of [RememberTabManager] which remembers by server, user, & item
*/
@Provides
@Singleton
fun rememberTabManager(
serverRepository: ServerRepository,
appPreference: DataStore<AppPreferences>,
@IoCoroutineScope scope: CoroutineScope,
): RememberTabManager = mockk()
@Provides @Provides
@Singleton @Singleton
@IoDispatcher @IoDispatcher
@ -267,6 +256,10 @@ object TestDatabaseModule {
@Singleton @Singleton
fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao()
@Provides
@Singleton
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
@Provides @Provides
@Singleton @Singleton
fun userPreferencesDataStore( fun userPreferencesDataStore(