From 3f60fa75505a4703c4b407239285ac6e45d16784 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 14 Jul 2026 14:17:31 -0400 Subject: [PATCH] Add option to override user profile settings for audio & subtitle languages (#1680) ## Description Adds settings under the "User profile settings" section which allows for specifying preferred languages for audio and subtitles and the preferred subtitle mode. By default (matching existing Wholphin behavior), the preferred languages and subtitle mode are derived from the user's profile settings provided by the server which can be set on the WebUI: User->Settings->Playback or Subtitles. However, this isn't well understood and Wholphin doesn't display these values anywhere, so many users were confused. The preferred languages and subtitle mode can now be configured from within Wholphin. Setting these will **not** update the server. The settings are **only** used locally. ### Related issues Related to #319 Closes #790 ### Testing Emulator manually changing some settings, added additional unit tests to ensure the overrides are respected when choosing tracks ## Screenshots ### Settings page settings_01 Large ### Clicking into "More user profile settings" settings_02 Large ### Language choice dialog Can filter languages by typing in the search box at the top settings_03 Large ## AI or LLM usage None --- .../35.json | 741 ++++++++++++++++++ .../damontecres/wholphin/MainActivity.kt | 13 +- .../damontecres/wholphin/MainContent.kt | 11 +- .../damontecres/wholphin/data/AppDatabase.kt | 3 +- .../wholphin/data/JellyfinServerDao.kt | 7 + .../wholphin/data/ServerRepository.kt | 16 +- .../wholphin/data/model/JellyfinServer.kt | 18 +- .../wholphin/preferences/AppPreference.kt | 8 + .../wholphin/preferences/UserPreferences.kt | 2 + .../preferences/UserProfileSettings.kt | 70 ++ .../wholphin/services/StreamChoiceService.kt | 53 +- .../services/UserPreferencesService.kt | 18 +- .../damontecres/wholphin/ui/Extensions.kt | 12 + .../ui/detail/music/NowPlayingPage.kt | 1 + .../wholphin/ui/nav/Destination.kt | 3 + .../wholphin/ui/nav/DestinationContent.kt | 5 + .../ui/preferences/PreferencesContent.kt | 1 - .../ui/preferences/PreferencesViewModel.kt | 19 +- .../user/FilterableLanguagePreference.kt | 145 ++++ .../ui/preferences/user/PreferredLanguage.kt | 55 ++ .../user/UserPreferencesViewModel.kt | 151 ++++ .../user/UserProfilePreferencesPage.kt | 321 ++++++++ .../wholphin/ui/util/StringProvider.kt | 10 + app/src/main/res/values/strings.xml | 24 + .../services/StreamChoiceServiceTest.kt | 90 ++- .../ui/playback/PlaybackViewModelTests.kt | 2 +- 26 files changed, 1751 insertions(+), 48 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/FilterableLanguagePreference.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/PreferredLanguage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserPreferencesViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt 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 }