diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json new file mode 100644 index 00000000..f82e6bdf --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json @@ -0,0 +1,741 @@ +{ + "formatVersion": 1, + "database": { + "version": 35, + "identityHash": "ff9ade58d4ac39e36335ac0df1405a5f", + "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, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', 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" + }, + { + "fieldPath": "appPreferences.preferredAudioLanguage", + "columnName": "preferredAudioLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.preferredSubtitleLanguage", + "columnName": "preferredSubtitleLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.subtitleMode", + "columnName": "subtitleMode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'USE_USER_PROFILE'" + } + ], + "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, 'ff9ade58d4ac39e36335ac0df1405a5f')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index a5b6e100..229fd378 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.SuggestionsSchedulerService import com.github.damontecres.wholphin.services.UpdateChecker +import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService @@ -93,6 +94,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var userPreferencesDataStore: DataStore + @Inject + lateinit var userPreferencesService: UserPreferencesService + @AuthOkHttpClient @Inject lateinit var okHttpClient: OkHttpClient @@ -226,8 +230,8 @@ class MainActivity : AppCompatActivity() { .fillMaxSize() .background(Color.Black), ) { - val appPreferences by userPreferencesDataStore.data.collectAsState(null) - if (appPreferences == null) { + val userPreferences by userPreferencesService.flow.collectAsState(null) + if (userPreferences == null) { // Show loading page if it is taking a while to get app preferences var showLoading by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -246,7 +250,8 @@ class MainActivity : AppCompatActivity() { } } } else { - appPreferences?.let { appPreferences -> + userPreferences?.let { userPreferences -> + val appPreferences = userPreferences.appPreferences CoilConfig( prefs = appPreferences, okHttpClient = okHttpClient, @@ -265,7 +270,7 @@ class MainActivity : AppCompatActivity() { MainContent( backStack = setupNavigationManager.backStack, navigationManager = navigationManager, - appPreferences = appPreferences, + userPreferences = userPreferences, backdropService = backdropService, screensaverService = screensaverService, modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index 714d80dc..78545930 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -27,7 +27,6 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface -import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager @@ -44,12 +43,12 @@ import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization fun MainContent( backStack: MutableList, navigationManager: NavigationManager, - appPreferences: AppPreferences, + userPreferences: UserPreferences, backdropService: BackdropService, screensaverService: ScreensaverService, modifier: Modifier = Modifier, ) { - val preferences by rememberUpdatedState(UserPreferences(appPreferences)) + val preferences by rememberUpdatedState(userPreferences) Surface( modifier = modifier @@ -59,7 +58,7 @@ fun MainContent( // val backStack = rememberNavBackStack(SetupDestination.Loading) // setupNavigationManager.backStack = backStack val interfaceCustomization = - remember(appPreferences) { InterfaceCustomization(appPreferences) } + remember(userPreferences.appPreferences) { InterfaceCustomization(userPreferences.appPreferences) } CompositionLocalProvider(LocalInterfaceCustomization provides interfaceCustomization) { NavDisplay( backStack = backStack, @@ -105,7 +104,7 @@ fun MainContent( mutableStateOf(true) } LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - if (!appPreferences.signInAutomatically) { + if (!userPreferences.appPreferences.signInAutomatically) { showContent = false } } @@ -140,7 +139,7 @@ fun MainContent( screenSaverState.show, Modifier.fillMaxSize(), ) { - AppScreensaver(appPreferences, Modifier.fillMaxSize()) + AppScreensaver(userPreferences.appPreferences, Modifier.fillMaxSize()) } } } 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 43676509..6a691723 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 @@ -49,7 +49,7 @@ import java.util.UUID SeerrUser::class, RememberedTab::class, ], - version = 34, + version = 35, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -67,6 +67,7 @@ import java.util.UUID AutoMigration(31, 32), AutoMigration(32, 33), AutoMigration(33, 34), + AutoMigration(34, 35), ], ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt index 3c2250fb..f72eb3b7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt @@ -9,6 +9,7 @@ import androidx.room.Update import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServerUsers import com.github.damontecres.wholphin.data.model.JellyfinUser +import kotlinx.coroutines.flow.Flow import java.util.UUID @Dao @@ -56,6 +57,12 @@ interface JellyfinServerDao { userId: UUID, ): JellyfinUser? + @Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId") + fun getUserFlow( + serverId: UUID, + userId: UUID, + ): Flow + @Query("DELETE FROM servers WHERE id = :serverId") fun deleteServer(serverId: UUID) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 5d94b008..e6d9c468 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -12,9 +12,12 @@ import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext @@ -57,7 +60,18 @@ class ServerRepository val currentServer: JellyfinServer? get() = _current.value?.server val currentServerFlow: Flow get() = _current.map { it?.server } val currentUser: JellyfinUser? get() = _current.value?.user - val currentUserFlow: Flow get() = _current.map { it?.user } + + @OptIn(ExperimentalCoroutinesApi::class) + val currentUserFlow: Flow + get() = + _current + .flatMapLatest { user -> + if (user?.user != null) { + serverDao.getUserFlow(user.user.serverId, user.user.id) + } else { + flow { emit(null) } + } + } /** * Adds a server to the app database and updated the [ApiClient] to the server's URL diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt index a357de4a..44c19b4a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt @@ -11,6 +11,8 @@ import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.Relation import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer +import com.github.damontecres.wholphin.preferences.SubtitleModePreference +import com.github.damontecres.wholphin.preferences.UserProfileSettings import com.github.damontecres.wholphin.ui.isNotNullOrBlank import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers @@ -65,11 +67,15 @@ data class JellyfinUser( val requireLogin: Boolean = false, val lastUsed: ZonedDateTime? = null, val uiLanguage: String? = null, + @Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(), ) { val hasPin: Boolean get() = pin.isNotNullOrBlank() override fun toString(): String = - "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})" + "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " + + "accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " + + "requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " + + "appPreferences=$appPreferences" } /** @@ -83,3 +89,13 @@ data class JellyfinServerUsers( ) val users: List, ) + +@Serializable +data class JellyfinUserPreferences( + @ColumnInfo(defaultValue = "") + val preferredAudioLanguage: String = UserProfileSettings.USE_USER_PROFILE, + @ColumnInfo(defaultValue = "") + val preferredSubtitleLanguage: String = UserProfileSettings.USE_USER_PROFILE, + @ColumnInfo(defaultValue = "USE_USER_PROFILE") + val subtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 5784d69b..12e39399 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -582,6 +582,13 @@ sealed interface AppPreference { destination = Destination.Settings(PreferenceScreenOption.ADVANCED), ) + val UserProfileSettings = + AppDestinationPreference( + title = R.string.override_user_profile_settings, + destination = Destination.UserAppPreferences, + summary = R.string.override_user_profile_settings_summary, + ) + val SkipIntros = AppChoicePreference( title = R.string.skip_intro_behavior, @@ -1116,6 +1123,7 @@ val basicPreferences = AppPreference.CustomizeHome, AppPreference.UserPinnedNavDrawerItems, AppPreference.UserInterfaceLanguage, + AppPreference.UserProfileSettings, ), ), PreferenceGroup( diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt index 35da376a..c90cd00a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.preferences +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences import org.jellyfin.sdk.model.api.SubtitlePlaybackMode import org.jellyfin.sdk.model.api.UserConfiguration @@ -8,6 +9,7 @@ import org.jellyfin.sdk.model.api.UserConfiguration */ data class UserPreferences( val appPreferences: AppPreferences, + val userPreferences: JellyfinUserPreferences?, ) val DefaultUserConfiguration = diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt new file mode 100644 index 00000000..31d0e98c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt @@ -0,0 +1,70 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup + +/** + * Represents settings from the user's profile on the server that may be overridden within the app + */ +object UserProfileSettings { + /** + * Special value that means the preferred language should be taken from the user's profile on the server + */ + const val USE_USER_PROFILE = "" + + /** + * Special value that means the user has no preferred language + */ + const val PREFER_ANY_LANGUAGE = "_any-language" + + val PreferredAudioLang = + AppClickablePreference( + title = R.string.preferred_audio_language, + getter = { }, + setter = { prefs, _ -> prefs }, + ) + + val PreferredSubtitleLang = + AppClickablePreference( + title = R.string.preferred_subtitle_language, + summary = null, + getter = { }, + setter = { prefs, _ -> prefs }, + ) + + val SubtitleModePref = + AppChoicePreference( + title = R.string.subtitle_mode, + defaultValue = SubtitleModePreference.USE_USER_PROFILE, + getter = { it.subtitleMode }, + setter = { prefs, value -> + prefs.copy(subtitleMode = value) + }, + displayValues = R.array.subtitle_mode_options, + indexToValue = { SubtitleModePreference.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val Preferences = + listOf( + PreferenceGroup( + title = R.string.profile_specific_settings_from_server, + preferences = + listOf( + PreferredAudioLang, + PreferredSubtitleLang, + SubtitleModePref, + ), + ), + ) +} + +enum class SubtitleModePreference { + USE_USER_PROFILE, + DEFAULT, + SMART, + ONLY_FORCED, + ALWAYS, + NONE, +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index c0a9465e..1bd2986c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -5,7 +5,9 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.SubtitleModePreference import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.UserProfileSettings import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty import org.jellyfin.sdk.model.api.BaseItemDto @@ -135,7 +137,17 @@ class StreamChoiceService val seriesLang = playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() } // If the user has chosen a different language for the series, prefer that - val audioLanguage = seriesLang ?: userConfig?.audioLanguagePreference + val audioLanguage = + if (seriesLang != null) { + seriesLang + } else { + val pref = prefs.userPreferences?.preferredAudioLanguage + when (pref) { + UserProfileSettings.USE_USER_PROFILE -> userConfig?.audioLanguagePreference + UserProfileSettings.PREFER_ANY_LANGUAGE -> null + else -> pref + } + } if (audioLanguage.isNotNullOrBlank()) { val sorted = @@ -226,23 +238,26 @@ class StreamChoiceService ): MediaStream? { if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { return null - } else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { + } + val seriesLang = + playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } + val subtitleLanguage = + if (seriesLang != null) { + seriesLang + } else { + val pref = prefs.userPreferences?.preferredSubtitleLanguage + when (pref) { + UserProfileSettings.USE_USER_PROFILE -> userConfig?.subtitleLanguagePreference + UserProfileSettings.PREFER_ANY_LANGUAGE -> null + else -> pref + }?.takeIf { it.isNotNullOrBlank() } + } + if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { // Client-side manual override: User selected "Only Forced" in player menu - val seriesLang = - playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } - val subtitleLanguage = - (seriesLang ?: userConfig?.subtitleLanguagePreference) - ?.takeIf { it.isNotNullOrBlank() } return findForcedTrack(candidates, subtitleLanguage, audioStreamLang) } else if (itemPlayback?.subtitleIndexEnabled == true) { return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } } else { - val seriesLang = - playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } - val subtitleLanguage = - (seriesLang ?: userConfig?.subtitleLanguagePreference) - ?.takeIf { it.isNotNullOrBlank() } - val subtitleMode = when { playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> { @@ -258,9 +273,17 @@ class StreamChoiceService else -> { // Fallback to the user's preference - userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT + when (prefs.userPreferences?.subtitleMode) { + SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode + SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT + SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART + SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED + SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS + SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE + null -> SubtitlePlaybackMode.DEFAULT + } } - } + } ?: SubtitlePlaybackMode.DEFAULT val candidates = candidates .sortedWith( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt index 0fe6be1e..03b8846f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt @@ -4,8 +4,9 @@ import androidx.datastore.core.DataStore import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import javax.inject.Inject import javax.inject.Singleton @@ -19,13 +20,10 @@ class UserPreferencesService private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, ) { - val flow = preferencesDataStore.data.map { UserPreferences(it) } - - suspend fun getCurrent(): UserPreferences = - serverRepository.currentUserDto!!.configuration.let { userConfig -> - val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - UserPreferences( - appPrefs, - ) + val flow: Flow = + preferencesDataStore.data.combine(serverRepository.currentUserFlow) { appPrefs, user -> + UserPreferences(appPrefs, user?.appPreferences) } + + suspend fun getCurrent(): UserPreferences = flow.first() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index c3037d75..2097dd9e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -448,3 +448,15 @@ fun Flow.collectLatestIn( * Easy way to combine two flows into a [Pair] */ fun Flow.combinePair(flow: Flow): Flow> = combine(flow) { t1, t2 -> Pair(t1, t2) } + +/** + * Easy way to combine three flows into a [Triple] + */ +fun Flow.combineTriple( + flow: Flow, + flow2: Flow, +): Flow> = + combine(flow) { t1, t2 -> Pair(t1, t2) } + .combine(flow2) { (t1, t2), t3 -> + Triple(t1, t2, t3) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index 5b571e4a..b61d6b89 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -104,6 +104,7 @@ fun NowPlayingPage( .collectAsState( UserPreferences( AppPreferences.getDefaultInstance(), + null, ), ).value.appPreferences val musicPrefs = preferences.musicPreferences diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 96863e05..accfd693 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -53,6 +53,9 @@ sealed class Destination( val hdr: Boolean, ) : Destination(true) + @Serializable + data object UserAppPreferences : Destination(true) + @Serializable data object Search : Destination() 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 12f4f4d9..e0f89304 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 @@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.ui.playback.PlayExternalPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage +import com.github.damontecres.wholphin.ui.preferences.user.UserProfilePreferencesPage import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage import com.github.damontecres.wholphin.ui.slideshow.SlideshowPage import org.jellyfin.sdk.model.api.BaseItemKind @@ -128,6 +129,10 @@ fun DestinationContent( ) } + Destination.UserAppPreferences -> { + UserProfilePreferencesPage(modifier) + } + is Destination.SeriesOverview -> { SeriesOverview( preferences = preferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 050e7798..d57de598 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -103,7 +103,6 @@ fun PreferencesContent( val state = rememberLazyListState() var preferences by remember { mutableStateOf(initialPreferences) } val currentUser by viewModel.currentUser.collectAsState() - val currentServer by seerrVm.currentSeerrServer.collectAsState(null) var showPinFlow by remember { mutableStateOf(false) } var showVersionDialog by remember { mutableStateOf(false) } val players by viewModel.externalPlayers.collectAsState() 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 f0e396cb..b72849a0 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 @@ -57,13 +57,18 @@ class PreferencesViewModel private val clientInfo: ClientInfo, private val updateChecker: UpdateChecker, ) : ViewModel() { - val currentUser - get() = - serverRepository.currentUserFlow.stateIn( - viewModelScope, - SharingStarted.Eagerly, - null, - ) + val currentUser = + serverRepository.currentUserFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) + val currentUserDto = + serverRepository.currentUserDtoFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) val seerrConnection = seerrServerRepository.connection diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/FilterableLanguagePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/FilterableLanguagePreference.kt new file mode 100644 index 00000000..c68239b0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/FilterableLanguagePreference.kt @@ -0,0 +1,145 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +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.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.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds + +/** + * A filterable list of language choices ([PreferredLanguageType]) + */ +@Composable +fun FilterableLanguagePreference( + @StringRes title: Int, + selectedOption: PreferredLanguageType, + options: List, + onClickOption: (PreferredLanguageType) -> Unit, + modifier: Modifier = Modifier, +) { + var query by remember { mutableStateOf("") } + + var filteredOptions by remember { mutableStateOf(options) } + LaunchedEffect(query) { + delay(500.milliseconds) + if (query.isNotNullOrBlank()) { + withContext(WholphinDispatchers.Default) { + val q = query.lowercase() + filteredOptions = + options + .filter { + // Only show languages, not "built-in" types + it is PreferredLanguageType.Language && + (it.name.lowercase().contains(q) || it.iso.contains(q)) + }.sortedByDescending { + // User is likely typing the first letters of a language, so show those first + // IE, for q="en", show English before Armenian and Bengali + (it as PreferredLanguageType.Language).name.lowercase().startsWith(q) + } + } + } else { + filteredOptions = options + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier, + ) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + val listState = rememberLazyListState() + val focusRequesters = remember { List(filteredOptions.size) { FocusRequester() } } + LaunchedEffect(Unit) { + focusRequesters.firstOrNull()?.tryRequestFocus() + } + LazyColumn( + state = listState, + modifier = Modifier, + ) { + item { + SearchEditTextBox( + value = query, + onValueChange = { query = it }, + onSearchClick = { focusRequesters.firstOrNull()?.tryRequestFocus() }, + modifier = + Modifier + .padding(bottom = 8.dp) + .fillMaxWidth(), + ) + } + itemsIndexed(filteredOptions) { index, option -> + if (option is PreferredLanguageType.Divider) { + HorizontalDivider() + } else { + ListItem( + selected = false, + onClick = { + onClickOption.invoke(option) + }, + leadingContent = { + SelectedLeadingContent(option == selectedOption) + }, + headlineContent = { + val text = + when (option) { + PreferredLanguageType.AnyLanguage -> option.displayString.getString() + is PreferredLanguageType.Language -> option.displayString.getString() + is PreferredLanguageType.ServerProfile -> stringResource(R.string.use_user_profile) + } + Text(text) + }, + supportingContent = + when (option) { + PreferredLanguageType.AnyLanguage, + is PreferredLanguageType.Language, + -> { + null + } + + is PreferredLanguageType.ServerProfile -> { + option.name?.let { + { + Text(it) + } + } + } + }, + modifier = Modifier.focusRequester(focusRequesters[index]), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/PreferredLanguage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/PreferredLanguage.kt new file mode 100644 index 00000000..dc8da479 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/PreferredLanguage.kt @@ -0,0 +1,55 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.util.ConcatStringProvider +import com.github.damontecres.wholphin.ui.util.EmptyStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider + +/** + * UI information for displaying a preferred language + */ +sealed interface PreferredLanguageType { + val displayString: StringProvider + + data class ServerProfile( + val name: String?, + ) : PreferredLanguageType { + override val displayString: StringProvider + get() = + ConcatStringProvider( + " - ", + buildList { + add(ResStringProvider(R.string.use_user_profile)) + if (name.isNotNullOrBlank()) { + add(StringStringProvider(name)) + } + }, + ) + } + + data object AnyLanguage : PreferredLanguageType { + override val displayString: StringProvider + get() = ResStringProvider(R.string.any_language) + } + + data class Language( + val iso: String, + val name: String, + ) : PreferredLanguageType { + override val displayString: StringProvider + get() = StringStringProvider(name) + } + + data object Divider : PreferredLanguageType { + override val displayString: StringProvider + get() = EmptyStringProvider + } +} + +data class PreferredLanguage( + val selected: PreferredLanguageType = PreferredLanguageType.ServerProfile(null), + val options: List = emptyList(), +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserPreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserPreferencesViewModel.kt new file mode 100644 index 00000000..8b4dd64f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserPreferencesViewModel.kt @@ -0,0 +1,151 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserProfileSettings +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.ui.combineTriple +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchIO +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.localizationApi +import org.jellyfin.sdk.model.api.CultureDto +import javax.inject.Inject + +@HiltViewModel +class UserPreferencesViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + val preferenceDataStore: DataStore, + val navigationManager: NavigationManager, + val backdropService: BackdropService, + val screensaverService: ScreensaverService, + private val serverRepository: ServerRepository, + private val serverDao: JellyfinServerDao, + ) : ViewModel() { + private val mutex = Mutex() + + val currentUser = serverRepository.currentUserFlow + val currentUserDto = + serverRepository.currentUserDtoFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) + private val serverLanguages = MutableStateFlow>(emptyList()) + + @OptIn(ExperimentalCoroutinesApi::class) + val userAppPreferences: StateFlow = + currentUser.mapNotNull { it?.appPreferences }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + JellyfinUserPreferences(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + private fun createLanguageFlow(isAudio: Boolean): StateFlow = + currentUser + .combineTriple(serverRepository.currentUserDtoFlow, serverLanguages) + .mapLatest { (user, userDto, serverLanguages) -> + if (user == null || userDto == null || serverLanguages.isEmpty() || user.id != userDto.id) { + return@mapLatest PreferredLanguage() + } + val languages = + serverLanguages.filter { it.threeLetterIsoLanguageName.isNotNullOrBlank() } + + // Language user has chosen from local app + val prefLang = user.appPreferences.let { if (isAudio) it.preferredAudioLanguage else it.preferredSubtitleLanguage } + + // Language user has chosen from server user profile + val userDisplayLang = + userDto.configuration + ?.let { if (isAudio) it.audioLanguagePreference else it.subtitleLanguagePreference } + .let { userLang -> + languages.firstOrNull { it.threeLetterIsoLanguageName == userLang }?.displayName + ?: userLang + } + val selected = + when (prefLang) { + UserProfileSettings.USE_USER_PROFILE -> { + PreferredLanguageType.ServerProfile(userDisplayLang) + } + + UserProfileSettings.PREFER_ANY_LANGUAGE -> { + PreferredLanguageType.AnyLanguage + } + + else -> { + languages + .firstOrNull { it.threeLetterIsoLanguageName == prefLang } + ?.let { + PreferredLanguageType.Language( + it.threeLetterIsoLanguageName!!, + it.displayName, + ) + } + } + } ?: PreferredLanguageType.ServerProfile(userDisplayLang) + val options = + buildList { + add(PreferredLanguageType.ServerProfile(userDisplayLang)) + add(PreferredLanguageType.AnyLanguage) + add(PreferredLanguageType.Divider) + languages.forEach { + if (it.threeLetterIsoLanguageName.isNotNullOrBlank()) { + add( + PreferredLanguageType.Language( + it.threeLetterIsoLanguageName!!, + it.displayName, + ), + ) + } + } + } + PreferredLanguage(selected, options) + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + PreferredLanguage(), + ) + + val audioLanguage: StateFlow = createLanguageFlow(true) + val subtitleLanguage: StateFlow = createLanguageFlow(false) + + init { + viewModelScope.launchIO { + serverLanguages.value = api.localizationApi.getCultures().content + } + } + + fun updatePreferences(block: (JellyfinUserPreferences) -> JellyfinUserPreferences) { + viewModelScope.launchIO { + mutex.withLock { + val user = currentUser.first { it != null }!! + val newAppPrefs = block.invoke(user.appPreferences) + serverDao.updateUser(user.copy(appPreferences = newAppPrefs)) + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt new file mode 100644 index 00000000..2cd74f1a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt @@ -0,0 +1,321 @@ +package com.github.damontecres.wholphin.ui.preferences.user + +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.preferences.AppChoicePreference +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.SubtitleModePreference +import com.github.damontecres.wholphin.preferences.UserProfileSettings +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.preferences.ChoicePreference +import com.github.damontecres.wholphin.ui.preferences.ClickPreference +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation +import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.SubtitlePlaybackMode +import timber.log.Timber + +/** + * Page for showing settings derived from the user's profile on the server + * + * @see UserProfileSettings + */ +@Composable +fun UserProfilePreferencesPage(modifier: Modifier = Modifier) { + Box( + modifier = modifier.background(MaterialTheme.colorScheme.background), + ) { + UserProfilePreferencesContent( + Modifier + .fillMaxWidth(.4f) + .fillMaxHeight() + .align(Alignment.TopEnd), + ) + } +} + +@Composable +fun UserProfilePreferencesContent( + modifier: Modifier = Modifier, + viewModel: UserPreferencesViewModel = hiltViewModel(), + onFocus: (Int, Int) -> Unit = { _, _ -> }, +) { + val context = LocalContext.current + val focusRequester = remember { FocusRequester() } + var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } + val state = rememberLazyListState() + + val userDto by viewModel.currentUserDto.collectAsState() + val preferences by viewModel.userAppPreferences.collectAsState() + val audioLanguagePref by viewModel.audioLanguage.collectAsState() + val subtitleLanguagePref by viewModel.subtitleLanguage.collectAsState() + var showPreferredLanguageDialog by remember { mutableStateOf(null) } + + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + // Forces the animated to trigger + visible = true + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn() + slideInHorizontally { it / 2 }, + exit = fadeOut() + slideOutHorizontally { it / 2 }, + modifier = modifier, + ) { + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + Column( + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), + ) { + Text( + text = stringResource(R.string.profile_specific_settings), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + LazyColumn( + state = state, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier, + ) { + UserProfileSettings.Preferences.forEachIndexed { groupIndex, group -> + item { + Text( + text = stringResource(group.title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + ) + } + val groupPreferences = + group.preferences + + group.conditionalPreferences + .filter { it.condition.invoke(preferences) } + .map { it.preferences } + .flatten() + groupPreferences.forEachIndexed { prefIndex, pref -> + pref as AppPreference + item { + val interactionSource = remember { MutableInteractionSource() } + val focused = interactionSource.collectIsFocusedAsState().value + + val modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ).onFocusChanged { + if (it.isFocused) focusedIndex = Pair(groupIndex, prefIndex) + } + + LaunchedEffect(focused) { + if (focused) { + focusedIndex = Pair(groupIndex, prefIndex) + onFocus.invoke(groupIndex, prefIndex) + } + } + when (pref) { + UserProfileSettings.PreferredAudioLang -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { showPreferredLanguageDialog = true }, + summary = audioLanguagePref.selected.displayString.getString(), + interactionSource = interactionSource, + modifier = modifier, + ) + } + + UserProfileSettings.PreferredSubtitleLang -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { showPreferredLanguageDialog = false }, + summary = subtitleLanguagePref.selected.displayString.getString(), + interactionSource = interactionSource, + modifier = modifier, + ) + } + + UserProfileSettings.SubtitleModePref -> { + pref as AppChoicePreference + val value = preferences.subtitleMode + val values = stringArrayResource(pref.displayValues).toList() + val summary = + when (value) { + SubtitleModePreference.USE_USER_PROFILE -> { + val userMode = userDto?.configuration?.subtitleMode + if (userMode != null) { + "${values[0]} - ${stringResource(userMode.stringRes)}" + } else { + values[0] + } + } + + else -> { + values[value.ordinal] + } + } + val selectedIndex = + remember(value) { pref.valueToIndex.invoke(value) } + ChoicePreference( + title = stringResource(pref.title), + summary = summary, + possibleValues = values, + selectedIndex = selectedIndex, + onValueChange = { index -> + viewModel.updatePreferences { + it.copy(subtitleMode = SubtitleModePreference.entries[index]) + } + }, + modifier = modifier, + interactionSource = interactionSource, + subtitleDisplay = { index, _ -> + val userMode = userDto?.configuration?.subtitleMode + if (index == 0 && userMode != null) { + { + Text(stringResource(userMode.stringRes)) + } + } else { + null + } + }, + ) + } + + else -> { + val value = pref.getter.invoke(preferences) + ComposablePreference( + preference = pref, + value = value, + onNavigate = viewModel.navigationManager::navigateTo, + onValueChange = { newValue -> + val validation = pref.validate(newValue) + when (validation) { + is PreferenceValidation.Invalid -> { + // TODO? + Toast + .makeText( + context, + validation.message, + Toast.LENGTH_SHORT, + ).show() + } + + PreferenceValidation.Valid -> { + viewModel.updatePreferences { + pref.setter(preferences, newValue) + } + } + } + }, + interactionSource = interactionSource, + modifier = modifier, + ) + } + } + } + } + } + } + } + } + showPreferredLanguageDialog?.let { isAudio -> + BasicDialog( + onDismissRequest = { showPreferredLanguageDialog = null }, + elevation = 3.dp, + ) { + val lang = if (isAudio) audioLanguagePref else subtitleLanguagePref + FilterableLanguagePreference( + title = if (isAudio) R.string.preferred_audio_language else R.string.preferred_subtitle_language, + selectedOption = lang.selected, + options = lang.options, + onClickOption = { option -> + val value = + when (option) { + PreferredLanguageType.AnyLanguage -> UserProfileSettings.PREFER_ANY_LANGUAGE + is PreferredLanguageType.Language -> option.iso + is PreferredLanguageType.ServerProfile -> UserProfileSettings.USE_USER_PROFILE + PreferredLanguageType.Divider -> throw IllegalStateException("Cannot click on a divider") + } + Timber.v("Updating language pref to %s", option) + viewModel.updatePreferences { + if (isAudio) { + it.copy(preferredAudioLanguage = value) + } else { + it.copy(preferredSubtitleLanguage = value) + } + } + showPreferredLanguageDialog = null + }, + modifier = + Modifier + .padding(16.dp) + .fillMaxSize(), + ) + } + } +} + +val SubtitlePlaybackMode.stringRes: Int + @StringRes get() = + when (this) { + SubtitlePlaybackMode.DEFAULT -> R.string.subtitle_mode_default + SubtitlePlaybackMode.ALWAYS -> R.string.subtitle_mode_always + SubtitlePlaybackMode.ONLY_FORCED -> R.string.subtitle_mode_only_forced + SubtitlePlaybackMode.NONE -> R.string.subtitle_mode_none + SubtitlePlaybackMode.SMART -> R.string.subtitle_mode_smart + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt index 6c3e7bca..c63c4067 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt @@ -93,3 +93,13 @@ data class ResProviderStringProvider( @NonRestartableComposable override fun getString(): String = stringResource(stringResId, argProvider.getString()) } + +@Serializable +data class ConcatStringProvider( + val separator: String, + val providers: List, +) : StringProvider { + @Composable + @NonRestartableComposable + override fun getString(): String = providers.map { it.getString() }.joinToString(separator) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54217145..89888b8c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -95,6 +95,7 @@ Playlists Preview User Profile Settings + User profile settings from server Queue Recap Recently added in %1$s @@ -844,4 +845,27 @@ Video tunneling Blocklisted Unavailable + Subtitle mode + + Use your profile setting + + Default + Smart + Only Forced + Always + None + + @string/use_user_profile + @string/subtitle_mode_default + @string/subtitle_mode_smart + @string/subtitle_mode_only_forced + @string/subtitle_mode_always + @string/subtitle_mode_none + + + Preferred subtitle language + Preferred audio language + More user profile settings + Override your user profile settings from the server + Any language diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt index 61f0f136..37671f8d 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt @@ -3,11 +3,14 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration +import com.github.damontecres.wholphin.preferences.SubtitleModePreference import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.UserProfileSettings import io.mockk.every import io.mockk.mockk import org.jellyfin.sdk.model.UUID @@ -800,6 +803,80 @@ class TestStreamChoiceServiceOnlyForcedClientOverride( } } +/** + * Tests for client-side "Only Forced" override (TrackIndex.ONLY_FORCED). + * This tests the findForcedTrack function with user subtitle language preference. + */ +@RunWith(Parameterized::class) +class TestStreamChoiceServiceAppPreferences( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + TestInput( + expectedIndex = 1, + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = null, + appSubtitleLang = UserProfileSettings.USE_USER_PROFILE, + ), + TestInput( + expectedIndex = 0, + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = null, + appSubtitleLang = "eng", + ), + TestInput( + expectedIndex = 1, + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = null, + appSubtitleLang = "spa", + ), + TestInput( + expectedIndex = 1, // ItemPlayback + userSubtitleMode = SubtitlePlaybackMode.ALWAYS, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = 1), + appSubtitleLang = "spa", + ), + ) + } +} + data class TestInput( val expectedIndex: Int?, val userSubtitleMode: SubtitlePlaybackMode?, @@ -809,6 +886,9 @@ data class TestInput( val subtitles: List, val itemPlayback: ItemPlayback? = null, val plc: PlaybackLanguageChoice? = null, + val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE, + val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE, + val appSubtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE, ) { override fun toString(): String = "test(mode=$userSubtitleMode, subtitles=${subtitles.map { it.toShortString() }})" } @@ -850,7 +930,15 @@ private fun runTest(input: TestInput) { candidates = input.subtitles, itemPlayback = input.itemPlayback, playbackLanguageChoice = input.plc, - prefs = UserPreferences(AppPreferences.getDefaultInstance()), + prefs = + UserPreferences( + AppPreferences.getDefaultInstance(), + JellyfinUserPreferences( + preferredAudioLanguage = input.appAudioLang, + preferredSubtitleLanguage = input.appSubtitleLang, + subtitleMode = input.appSubtitleMode, + ), + ), ) Assert.assertEquals(input.expectedIndex, result?.index) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt index 33e7973d..adb29999 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -132,7 +132,7 @@ class PlaybackViewModelTests { private fun setupPreferences(block: PlaybackPreferences.Builder.() -> Unit) { val appPrefs = AppPreferences.getDefaultInstance().updatePlaybackPreferences(block) - val prefs = UserPreferences(appPrefs) + val prefs = UserPreferences(appPrefs, null) coEvery { mockUserPreferencesService.getCurrent() } returns prefs }