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
<img width="1280" height="720" alt="settings_01 Large"
src="https://github.com/user-attachments/assets/28cc2f6b-3a60-42a4-b268-48695086d566"
/>

### Clicking into "More user profile settings"
<img width="1280" height="720" alt="settings_02 Large"
src="https://github.com/user-attachments/assets/6c3be3aa-c177-4d44-8060-b76d15fb10d7"
/>

### Language choice dialog

Can filter languages by typing in the search box at the top

<img width="1280" height="720" alt="settings_03 Large"
src="https://github.com/user-attachments/assets/c014d0ba-8d5b-4201-a6ee-4876b478148c"
/>


## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-07-14 14:17:31 -04:00 • committed by GitHub
parent 077b7e4570
commit 3f60fa7550
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1751 additions and 48 deletions

View file

@ -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')"
]
}
}

View file

@ -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<AppPreferences>
@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(),

View file

@ -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<SetupDestination>,
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())
}
}
}

View file

@ -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)

View file

@ -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<JellyfinUser?>
@Query("DELETE FROM servers WHERE id = :serverId")
fun deleteServer(serverId: UUID)

View file

@ -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<JellyfinServer?> get() = _current.map { it?.server }
val currentUser: JellyfinUser? get() = _current.value?.user
val currentUserFlow: Flow<JellyfinUser?> get() = _current.map { it?.user }
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserFlow: Flow<JellyfinUser?>
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

View file

@ -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<JellyfinUser>,
)
@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,
)

View file

@ -582,6 +582,13 @@ sealed interface AppPreference<Pref, T> {
destination = Destination.Settings(PreferenceScreenOption.ADVANCED),
)
val UserProfileSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.override_user_profile_settings,
destination = Destination.UserAppPreferences,
summary = R.string.override_user_profile_settings_summary,
)
val SkipIntros =
AppChoicePreference<AppPreferences, SkipSegmentBehavior>(
title = R.string.skip_intro_behavior,
@ -1116,6 +1123,7 @@ val basicPreferences =
AppPreference.CustomizeHome,
AppPreference.UserPinnedNavDrawerItems,
AppPreference.UserInterfaceLanguage,
AppPreference.UserProfileSettings,
),
),
PreferenceGroup(

View file

@ -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 =

View file

@ -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<JellyfinUserPreferences>(
title = R.string.preferred_audio_language,
getter = { },
setter = { prefs, _ -> prefs },
)
val PreferredSubtitleLang =
AppClickablePreference<JellyfinUserPreferences>(
title = R.string.preferred_subtitle_language,
summary = null,
getter = { },
setter = { prefs, _ -> prefs },
)
val SubtitleModePref =
AppChoicePreference<JellyfinUserPreferences, SubtitleModePreference>(
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,
}

View file

@ -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(

View file

@ -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<AppPreferences>,
) {
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<UserPreferences> =
preferencesDataStore.data.combine(serverRepository.currentUserFlow) { appPrefs, user ->
UserPreferences(appPrefs, user?.appPreferences)
}
suspend fun getCurrent(): UserPreferences = flow.first()
}

View file

@ -448,3 +448,15 @@ fun <T> Flow<T>.collectLatestIn(
* Easy way to combine two flows into a [Pair]
*/
fun <T1, T2> Flow<T1>.combinePair(flow: Flow<T2>): Flow<Pair<T1, T2>> = combine(flow) { t1, t2 -> Pair(t1, t2) }
/**
* Easy way to combine three flows into a [Triple]
*/
fun <T1, T2, T3> Flow<T1>.combineTriple(
flow: Flow<T2>,
flow2: Flow<T3>,
): Flow<Triple<T1, T2, T3>> =
combine(flow) { t1, t2 -> Pair(t1, t2) }
.combine(flow2) { (t1, t2), t3 ->
Triple(t1, t2, t3)
}

View file

@ -104,6 +104,7 @@ fun NowPlayingPage(
.collectAsState(
UserPreferences(
AppPreferences.getDefaultInstance(),
null,
),
).value.appPreferences
val musicPrefs = preferences.musicPreferences

View file

@ -53,6 +53,9 @@ sealed class Destination(
val hdr: Boolean,
) : Destination(true)
@Serializable
data object UserAppPreferences : Destination(true)
@Serializable
data object Search : Destination()

View file

@ -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,

View file

@ -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()

View file

@ -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

View file

@ -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<PreferredLanguageType>,
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]),
)
}
}
}
}
}

View file

@ -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<PreferredLanguageType> = emptyList(),
)

View file

@ -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<AppPreferences>,
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<List<CultureDto>>(emptyList())
@OptIn(ExperimentalCoroutinesApi::class)
val userAppPreferences: StateFlow<JellyfinUserPreferences> =
currentUser.mapNotNull { it?.appPreferences }.stateIn(
viewModelScope,
SharingStarted.Eagerly,
JellyfinUserPreferences(),
)
@OptIn(ExperimentalCoroutinesApi::class)
private fun createLanguageFlow(isAudio: Boolean): StateFlow<PreferredLanguage> =
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<PreferredLanguage> = createLanguageFlow(true)
val subtitleLanguage: StateFlow<PreferredLanguage> = 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))
}
}
}
}

View file

@ -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<Boolean?>(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<JellyfinUserPreferences, Any>
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<JellyfinUserPreferences, SubtitleModePreference>
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
}

View file

@ -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>,
) : StringProvider {
@Composable
@NonRestartableComposable
override fun getString(): String = providers.map { it.getString() }.joinToString(separator)
}

View file

@ -95,6 +95,7 @@
<string name="playlists">Playlists</string>
<string name="preview">Preview</string>
<string name="profile_specific_settings">User Profile Settings</string>
<string name="profile_specific_settings_from_server">User profile settings from server</string>
<string name="queue">Queue</string>
<string name="recap">Recap</string>
<string name="recently_added_in">Recently added in %1$s</string>
@ -844,4 +845,27 @@
<string name="video_tunneling">Video tunneling</string>
<string name="blocklisted">Blocklisted</string>
<string name="unavailable">Unavailable</string>
<string name="subtitle_mode">Subtitle mode</string>
<string name="use_user_profile">Use your profile setting</string>
<string name="subtitle_mode_default">Default</string>
<string name="subtitle_mode_smart">Smart</string>
<string name="subtitle_mode_only_forced">Only Forced</string>
<string name="subtitle_mode_always">Always</string>
<string name="subtitle_mode_none">None</string>
<string-array name="subtitle_mode_options">
<item>@string/use_user_profile</item>
<item>@string/subtitle_mode_default</item>
<item>@string/subtitle_mode_smart</item>
<item>@string/subtitle_mode_only_forced</item>
<item>@string/subtitle_mode_always</item>
<item>@string/subtitle_mode_none</item>
</string-array>
<string name="preferred_subtitle_language">Preferred subtitle language</string>
<string name="preferred_audio_language">Preferred audio language</string>
<string name="override_user_profile_settings">More user profile settings</string>
<string name="override_user_profile_settings_summary">Override your user profile settings from the server</string>
<string name="any_language">Any language</string>
</resources>

View file

@ -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<TestInput> =
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<MediaStream>,
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)
}

View file

@ -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
}