diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json new file mode 100644 index 00000000..a5709ec2 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json @@ -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')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index 69f2e745..43676509 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -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.PlaybackEffect 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.SeerrUser import com.github.damontecres.wholphin.ui.components.ViewOptions @@ -46,8 +47,9 @@ import java.util.UUID ItemTrackModification::class, SeerrServer::class, SeerrUser::class, + RememberedTab::class, ], - version = 33, + version = 34, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -64,6 +66,7 @@ import java.util.UUID AutoMigration(30, 31), AutoMigration(31, 32), AutoMigration(32, 33), + AutoMigration(33, 34), ], ) @TypeConverters(Converters::class) @@ -81,6 +84,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun seerrServerDao(): SeerrServerDao abstract fun playbackEffectDao(): PlaybackEffectDao + + abstract fun rememberedTabDao(): RememberedTabDao } class Converters { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt new file mode 100644 index 00000000..13398228 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt @@ -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) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt new file mode 100644 index 00000000..2fc6f702 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt @@ -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, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 281eddc2..018b50ba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -6,7 +6,11 @@ import android.os.Build import androidx.core.content.edit import androidx.datastore.core.DataStore 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.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.RememberedTab import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences 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.util.Version import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.io.File +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -44,6 +51,8 @@ class AppUpgradeHandler @param:ApplicationContext private val context: Context, private val appPreferences: DataStore, private val seerrServerDao: SeerrServerDao, + private val serverDao: JellyfinServerDao, + private val rememberedTabDao: RememberedTabDao, ) { val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0) 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() + 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") + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt new file mode 100644 index 00000000..515caad4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt @@ -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)) + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index d1ed9eac..25dd1be3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -1,18 +1,12 @@ package com.github.damontecres.wholphin.services.hilt import android.content.Context -import androidx.datastore.core.DataStore import androidx.work.WorkManager import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R 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.util.CoroutineContextApiClientFactory -import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.RememberTabManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -22,7 +16,6 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch import okhttp3.OkHttpClient import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.android.androidDevice @@ -160,51 +153,6 @@ object AppModule { @Singleton fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi() - /** - * Implementation of [RememberTabManager] which remembers by server, user, & item - */ - @Provides - @Singleton - fun rememberTabManager( - serverRepository: ServerRepository, - appPreference: DataStore, - @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 @Singleton @IoDispatcher diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index 62609628..97718089 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.Migrations import com.github.damontecres.wholphin.data.PlaybackEffectDao 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.ServerPreferencesDao import com.github.damontecres.wholphin.preferences.AppPreferences @@ -74,6 +75,10 @@ object DatabaseModule { @Singleton fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + @Provides + @Singleton + fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt index 4e3b13ab..e00a2185 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CircularProgress.kt @@ -7,7 +7,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -15,6 +18,9 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.delay +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds @Composable 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), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt index f54fda5b..293005dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color 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.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.StringStringProvider import timber.log.Timber @Composable fun TabRow( selectedTabIndex: Int, - tabs: List, - focusRequesters: List, + tabs: List, onClick: (Int) -> Unit, modifier: Modifier = Modifier, ) { val state = rememberLazyListState() LaunchedEffect(selectedTabIndex) { - if (selectedTabIndex >= 0) { + if (selectedTabIndex in tabs.indices) { state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt()) } } var rowHasFocus by remember { mutableStateOf(false) } val currentSelectedTabIndex by rememberUpdatedState(selectedTabIndex) - val currentFocusRequesters by rememberUpdatedState(focusRequesters) +// val currentFocusRequesters by rememberUpdatedState(focusRequesters) val currentOnClick by rememberUpdatedState(onClick) LazyRow( @@ -76,21 +75,21 @@ fun TabRow( // If entering from left or right, use last or first tab // Otherwise use the selected tab val index = currentSelectedTabIndex - val requesters = currentFocusRequesters +// val requesters = currentFocusRequesters Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$index") - val focusRequester = + val tab = if (requestedFocusDirection == FocusDirection.Left) { - requesters.lastOrNull() + tabs.lastOrNull() } else if (requestedFocusDirection == FocusDirection.Right) { - requesters.firstOrNull() + tabs.firstOrNull() } 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 onTabClick = remember(index) { @@ -99,12 +98,12 @@ fun TabRow( } } Tab( - title = tabTitle, + title = tab.title.getString(), selected = index == selectedTabIndex, rowActive = rowHasFocus, interactionSource = interactionSource, onClick = onTabClick, - modifier = Modifier.focusRequester(focusRequesters.getOrElse(index) { FocusRequester() }), + modifier = Modifier.focusRequester(tab.tabFocusRequester), ) } } @@ -195,9 +194,11 @@ private fun TabRowPreview() { WholphinTheme { Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { TabRow( - selectedTabIndex = 1, - tabs = listOf("Tab 1", "Tab 2", "Tab 3"), - focusRequesters = listOf(), + selectedTabIndex = -1, + tabs = + remember { + listOf("Tab 1", "Tab 2", "Tab 3").map { TabDetails(StringStringProvider(it)) } + }, onClick = {}, ) Tab( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt new file mode 100644 index 00000000..7d89e666 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt @@ -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(UNSET) + val state: StateFlow = _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, + modifier: Modifier = Modifier, + showTabs: Boolean = true, + viewModel: TabViewModel = + hiltViewModel( + 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) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index b6f5b19a..18af298b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -1,25 +1,15 @@ 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.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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 androidx.lifecycle.ViewModel 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.ui.components.CollectionFolderView 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.data.VideoSortOptions import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid 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.tryRequestFocus -import com.github.damontecres.wholphin.util.RememberTabManager +import com.github.damontecres.wholphin.ui.util.StringStringProvider +import com.github.damontecres.wholphin.util.DataLoadingState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi +import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -56,24 +49,35 @@ class LiveTvCollectionViewModel val api: ApiClient, val serverRepository: ServerRepository, val navigationManager: NavigationManager, - val rememberTabManager: RememberTabManager, val backdropService: BackdropService, - ) : ViewModel(), - RememberTabManager by rememberTabManager { - val recordingFolders = MutableStateFlow>(emptyList()) + ) : ViewModel() { + val state = MutableStateFlow(LiveTvRecordingFolderState()) init { viewModelScope.launchIO { - val folders = - api.liveTvApi - .getRecordingFolders(userId = serverRepository.currentUser?.id) - .content.items - .map { TabId(it.name ?: "Recordings", it.id) } - recordingFolders.value = folders + try { + val folders = + api.liveTvApi + .getRecordingFolders(userId = serverRepository.currentUser?.id) + .content.items + .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> = DataLoadingState.Pending, +) + data class TabId( val title: String, val id: UUID, @@ -86,103 +90,91 @@ fun CollectionFolderLiveTv( modifier: Modifier = Modifier, viewModel: LiveTvCollectionViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } - val folders by viewModel.recordingFolders.collectAsState() + val state by viewModel.state.collectAsState() - val tvGuideStr = stringResource(R.string.tv_guide) - val tvDvrStr = stringResource(R.string.tv_dvr_schedule) - val tabs = - remember(folders) { - listOf( - TabId(tvGuideStr, UUID.randomUUID()), - TabId(tvDvrStr, UUID.randomUUID()), - ) + folders + when (val st = state.folders) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } - val tabFocusRequesters = remember(tabs.size) { List(tabs.size) { FocusRequester() } } - - 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, - ) + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(modifier) } - when (selectedTabIndex) { - 0 -> { - TvGuideGrid( - preferences = preferences, - onRowPosition = { - showHeader = it <= 0 - }, - Modifier - .fillMaxSize() - .focusRequester(focusRequester), - ) - } - 1 -> { - DvrSchedule( - requestFocusAfterLoading = true, - focusRequesterOnEmpty = tabFocusRequesters[1], - modifier = - Modifier - .fillMaxSize() - .focusRequester(focusRequester), - ) - } + is DataLoadingState.Success> -> { + val tabs = + remember { + listOf( + TabDetails(R.string.tv_guide), + TabDetails(R.string.tv_dvr_schedule), + ) + + st.data.map { + TabDetails(StringStringProvider(it.title)) + } + } + val onClickItem = + remember { + { position: Int, item: BaseItem -> + viewModel.navigationManager.navigateTo(item.destination()) + } + } - else -> { - val folderIndex = selectedTabIndex - 2 - if (folderIndex in folders.indices) { - CollectionFolderView( - preferences = preferences, - onClickItem = onClickItem, - itemId = folders[folderIndex].id, - initialFilter = CollectionFolderFilter(), - showTitle = false, - recursive = false, - sortOptions = VideoSortOptions, - modifier = Modifier, - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - defaultViewOptions = ViewOptions(), - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), - ) - } else { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + var showHeader by rememberSaveable { mutableStateOf(true) } + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, + modifier = modifier, + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { + 0 -> { + TvGuideGrid( + preferences = preferences, + onRowPosition = { + showHeader = it <= 0 + }, + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), + ) + } + + 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) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index 6651ae9d..cb1d1812 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -1,24 +1,14 @@ 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.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.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.GenreCardGrid 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.data.MovieSortOptions 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.preferences.PreferencesViewModel import org.jellyfin.sdk.model.api.BaseItemKind @@ -45,51 +35,25 @@ fun CollectionFolderMovie( modifier: Modifier = Modifier, preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } - val tabs = - listOf( - stringResource(R.string.recommended), - stringResource(R.string.library), - stringResource(R.string.collections), - stringResource(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() - } + remember { + listOf( + TabDetails(R.string.recommended), + TabDetails(R.string.library), + TabDetails(R.string.collections), + TabDetails(R.string.genres), + ) + } var showHeader by rememberSaveable { mutableStateOf(true) } - Column( + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(vertical = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) - } - when (selectedTabIndex) { + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Recommended 0 -> { RecommendedMovie( @@ -101,7 +65,7 @@ fun CollectionFolderMovie( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -128,12 +92,12 @@ fun CollectionFolderMovie( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = true, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -160,12 +124,12 @@ fun CollectionFolderMovie( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -178,12 +142,12 @@ fun CollectionFolderMovie( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt index 274e8343..431e8e8e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt @@ -1,24 +1,14 @@ 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.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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 androidx.lifecycle.ViewModel 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.GridClickActions 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.data.AlbumSortOptions import com.github.damontecres.wholphin.ui.data.ArtistSortOptions import com.github.damontecres.wholphin.ui.data.SongSortOptions 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.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.RememberTabManager import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -66,10 +55,8 @@ class CollectionFolderMusicViewModel private val musicService: MusicService, private val navigationManager: NavigationManager, val backdropService: BackdropService, - private val rememberTabManager: RememberTabManager, @Assisted private val itemId: UUID, - ) : ViewModel(), - RememberTabManager by rememberTabManager { + ) : ViewModel() { @AssistedFactory interface Factory { fun create(itemId: UUID): CollectionFolderMusicViewModel @@ -132,30 +119,16 @@ fun CollectionFolderMusic( creationCallback = { it.create(destination.itemId) }, ), ) { - val rememberedTabIndex = - remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } - val tabs = - listOf( - stringResource(R.string.recommended), - stringResource(R.string.albums), - stringResource(R.string.artists), - stringResource(R.string.genres), - stringResource(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() - } - + remember { + listOf( + TabDetails(R.string.recommended), + TabDetails(R.string.albums), + TabDetails(R.string.artists), + TabDetails(R.string.genres), + TabDetails(R.string.songs), + ) + } var showHeader by rememberSaveable { mutableStateOf(true) } val actions = @@ -167,27 +140,13 @@ fun CollectionFolderMusic( onClickPlayRemoteButton = viewModel::onClickPlayRemoteButton, ) } - - Column( + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(vertical = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) - } - when (selectedTabIndex) { + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Recommended 0 -> { RecommendedMusic( @@ -199,7 +158,7 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -224,12 +183,12 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = true, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -254,12 +213,12 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -272,7 +231,7 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -297,17 +256,17 @@ fun CollectionFolderMusic( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = true, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index 35875351..2f8fb909 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -1,102 +1,77 @@ 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.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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 androidx.lifecycle.ViewModel import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.filter.DefaultTvFilterOptions import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter 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.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.RecommendedTvShow 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.data.SeriesSortOptions -import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import com.github.damontecres.wholphin.ui.tryRequestFocus +import dagger.hilt.android.lifecycle.HiltViewModel import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType +import javax.inject.Inject + +@HiltViewModel +class CollectionFolderTvViewModel + @Inject + constructor( + val navigationManager: NavigationManager, + ) : ViewModel() @Composable fun CollectionFolderTv( preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - preferencesViewModel: PreferencesViewModel = hiltViewModel(), + viewModel: CollectionFolderTvViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } - val tabs = - listOf( - stringResource(R.string.recommended), - stringResource(R.string.library), - stringResource(R.string.genres), - stringResource(R.string.studios), - ) - 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("tv", selectedTabIndex) - preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) - preferencesViewModel.backdropService.clearBackdrop() - } - - val onClickItem = { item: BaseItem -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - } + remember { + listOf( + TabDetails(R.string.recommended), + TabDetails(R.string.library), + TabDetails(R.string.genres), + TabDetails(R.string.studios), + ) + } + val onClickItem = + remember { + { position: Int, item: BaseItem -> + viewModel.navigationManager.navigateTo(item.destination()) + } + } var showHeader by rememberSaveable { mutableStateOf(true) } - Column( + TabbedPage( + itemId = destination.itemId.toString(), + tabs = tabs, modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTabIndex, - modifier = - Modifier - .padding(vertical = 16.dp) - .focusRequester(firstTabFocusRequester), - tabs = tabs, - onClick = { selectedTabIndex = it }, - focusRequesters = tabFocusRequesters, - ) - } - when (selectedTabIndex) { + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Recommended 0 -> { RecommendedTvShow( @@ -108,7 +83,7 @@ fun CollectionFolderTv( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -132,15 +107,13 @@ fun CollectionFolderTv( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, - onClickItem = { _, item -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - }, + onClickItem = onClickItem, playEnabled = false, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -153,7 +126,7 @@ fun CollectionFolderTv( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } @@ -165,12 +138,12 @@ fun CollectionFolderTv( modifier = Modifier .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index b0124313..f9aedff3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -1,36 +1,30 @@ 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.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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 androidx.lifecycle.ViewModel import com.github.damontecres.wholphin.R 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.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride 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.ErrorMessage 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.ViewOptionsSquare 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.SortAndDirection 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.preferences.PreferencesViewModel -import com.github.damontecres.wholphin.ui.tryRequestFocus +import dagger.hilt.android.lifecycle.HiltViewModel import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder +import javax.inject.Inject + +@HiltViewModel +class FavoritesViewModel + @Inject + constructor( + val navigationManager: NavigationManager, + ) : ViewModel() @Composable fun FavoritesPage( preferences: UserPreferences, modifier: Modifier = Modifier, - preferencesViewModel: PreferencesViewModel = hiltViewModel(), + viewModel: FavoritesViewModel = hiltViewModel(), ) { - val uiPrefs = preferences.appPreferences.interfacePreferences - val rememberedTabIndex = + val tabs = remember { - preferencesViewModel.getRememberedTab( - preferences, - NavDrawerItem.Favorites.id, - 0, + listOf( + TabDetails(R.string.movies), + TabDetails(R.string.tv_shows), + 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) } - val onClickItem = { item: BaseItem -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - } - - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Column( +// LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + TabbedPage( + itemId = NavDrawerItem.Favorites.id, + tabs = tabs, + showTabs = showHeader, 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, - ) - } - val actions = - remember { - GridClickActions( - onClickItem = { _, item -> onClickItem.invoke(item) }, - ) - } - + ) { tabIndex, tabDetails -> // TODO playEnabled = true for movies & episodes - when (selectedTabIndex) { + when (tabIndex) { // Movies 0 -> { CollectionFolderView( @@ -141,13 +104,13 @@ fun FavoritesPage( Modifier .padding(start = 16.dp) .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -173,13 +136,13 @@ fun FavoritesPage( Modifier .padding(start = 16.dp) .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -206,13 +169,13 @@ fun FavoritesPage( Modifier .padding(start = 16.dp) .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -238,13 +201,13 @@ fun FavoritesPage( Modifier .padding(start = 16.dp) .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -270,13 +233,13 @@ fun FavoritesPage( Modifier .padding(start = 16.dp) .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } @@ -307,18 +270,18 @@ fun FavoritesPage( Modifier .padding(start = 16.dp) .fillMaxSize() - .focusRequester(focusRequester), + .focusRequester(tabDetails.contentFocusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, filterOptions = listOf(), - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 5d071387..a3ca1358 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -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.HeaderUtils 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.TitleOrLogo 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.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.StringStringProvider import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.MediaSourceInfo @@ -120,13 +122,21 @@ fun SeriesOverviewContent( var requestFocusAfterSeason by remember { mutableStateOf(false) } 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 = - seasons.map { season -> - season?.name - ?: season?.data?.indexNumber?.let { "$seasonStr $it" } - ?: "" + seasons.mapIndexed { index, season -> + TabDetails( + 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) @@ -172,7 +182,6 @@ fun SeriesOverviewContent( requestFocusAfterSeason = true } }, - focusRequesters = focusRequesters, modifier = Modifier .focusRequester(tabRowFocusRequester) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt index eba02b62..9751a6d0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt @@ -1,111 +1,78 @@ 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.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -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.compose.ui.focus.focusRequester import com.github.damontecres.wholphin.R 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.TabRow -import com.github.damontecres.wholphin.ui.logTab +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabbedPage import com.github.damontecres.wholphin.ui.nav.NavDrawerItem -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel @Composable fun DiscoverPage( preferences: UserPreferences, modifier: Modifier = Modifier, - preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - val rememberedTabIndex = - remember { preferencesViewModel.getRememberedTab(preferences, NavDrawerItem.Discover.id, 0) } - val tabs = - listOf( - stringResource(R.string.discover), - stringResource(R.string.request), - stringResource(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, + remember { + listOf( + TabDetails(R.string.discover), + TabDetails(R.string.request), + TabDetails(R.string.search), ) } - when (selectedTabIndex) { + var showHeader by rememberSaveable { mutableStateOf(true) } + + TabbedPage( + itemId = NavDrawerItem.Discover.id, + tabs = tabs, + modifier = modifier, + showTabs = showHeader, + ) { tabIndex, tabDetails -> + when (tabIndex) { // Discover 0 -> { SeerrDiscoverPage( preferences = preferences, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), ) } // Requests 1 -> { SeerrRequestsPage( - focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + focusRequesterOnEmpty = tabDetails.tabFocusRequester, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), ) } // Search 2 -> { - LaunchedEffect(Unit) { - preferencesViewModel.backdropService.clearBackdrop() - } DiscoverSearchPage( preferences = preferences, - modifier = Modifier.fillMaxSize(), + modifier = + Modifier + .fillMaxSize() + .focusRequester(tabDetails.contentFocusRequester), ) } else -> { - ErrorMessage("Invalid tab index $selectedTabIndex", null) + ErrorMessage("Invalid tab index $tabIndex", null) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index c3ff42da..aec58ce1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -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.components.ExpandableFaButton 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.VoiceInputManager 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( modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -610,12 +620,7 @@ fun SearchPage( ) { TabRow( selectedTabIndex = selectedTab, - tabs = - listOf( - stringResource(R.string.library), - stringResource(R.string.discover), - ), - focusRequesters = tabFocusRequesters, + tabs = tabs, onClick = { selectedTab = it val row = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 3e5ab947..db723522 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -359,6 +359,7 @@ fun DestinationContent( } Destination.Discover -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverPage( preferences = preferences, modifier = modifier, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 718febf3..f0e396cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -28,7 +28,6 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState -import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow @@ -52,14 +51,12 @@ class PreferencesViewModel val navigationManager: NavigationManager, val backdropService: BackdropService, val screensaverService: ScreensaverService, - private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, private val seerrServerRepository: SeerrServerRepository, private val deviceInfo: DeviceInfo, private val clientInfo: ClientInfo, private val updateChecker: UpdateChecker, - ) : ViewModel(), - RememberTabManager by rememberTabManager { + ) : ViewModel() { val currentUser get() = serverRepository.currentUserFlow.stateIn( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt deleted file mode 100644 index 46ec3606..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt +++ /dev/null @@ -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, - ) -} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index bf98744a..12631e17 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -180,7 +180,7 @@ enum DisplayToggle{ message InterfacePreferences { ThemeSongVolume play_theme_songs = 1; bool remember_selected_tab = 2; - map remembered_tabs = 3; + map remembered_tabs = 3 [deprecated = true]; AppThemeColors app_theme_colors = 4; bool nav_drawer_switch_on_focus = 5; bool show_clock = 6; diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index 8e43a4b0..e2026a4d 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.Migrations import com.github.damontecres.wholphin.data.PlaybackEffectDao 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.ServerPreferencesDao 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.StandardOkHttpClient import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory -import com.github.damontecres.wholphin.util.RememberTabManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -166,17 +166,6 @@ object TestModule { return api } - /** - * Implementation of [RememberTabManager] which remembers by server, user, & item - */ - @Provides - @Singleton - fun rememberTabManager( - serverRepository: ServerRepository, - appPreference: DataStore, - @IoCoroutineScope scope: CoroutineScope, - ): RememberTabManager = mockk() - @Provides @Singleton @IoDispatcher @@ -267,6 +256,10 @@ object TestDatabaseModule { @Singleton fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + @Provides + @Singleton + fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() + @Provides @Singleton fun userPreferencesDataStore(