Support configuring interface language per user

This commit is contained in:
Damontecres 2026-05-16 16:48:56 -04:00
parent 59ccce9d54
commit 4dc40f2c3c
No known key found for this signature in database
11 changed files with 894 additions and 1 deletions

View file

@ -182,6 +182,9 @@ configure<ApplicationExtension> {
lint {
disable.add("MissingTranslation")
}
androidResources {
generateLocaleConfig = true
}
}
androidComponents {

View file

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

View file

@ -84,6 +84,15 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="false" />
</service>
</application>
</manifest>

View file

@ -40,7 +40,7 @@ import java.util.UUID
SeerrUser::class,
],
version = 31,
version = 32,
exportSchema = true,
autoMigrations = [
AutoMigration(3, 4),
@ -55,6 +55,7 @@ import java.util.UUID
AutoMigration(12, 20),
AutoMigration(20, 30),
AutoMigration(30, 31),
AutoMigration(31, 32),
],
)
@TypeConverters(Converters::class)

View file

@ -58,6 +58,7 @@ data class JellyfinUser(
val serverId: UUID,
val accessToken: String?,
val pin: String? = null,
val uiLanguage: String? = null,
) {
val hasPin: Boolean get() = pin.isNotNullOrBlank()

View file

@ -673,6 +673,13 @@ sealed interface AppPreference<Pref, T> {
summary = R.string.customize_home_summary,
)
val UserInterfaceLanguage =
AppClickablePreference<AppPreferences>(
title = R.string.user_interface_language,
getter = { },
setter = { prefs, _ -> prefs },
)
val SendCrashReports =
AppSwitchPreference<AppPreferences>(
title = R.string.send_crash_reports,
@ -1079,6 +1086,7 @@ val basicPreferences =
AppPreference.RequireProfilePin,
AppPreference.CustomizeHome,
AppPreference.UserPinnedNavDrawerItems,
AppPreference.UserInterfaceLanguage,
),
),
PreferenceGroup(

View file

@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.asFlow
import androidx.lifecycle.lifecycleScope
import com.github.damontecres.wholphin.BuildConfig
@ -289,6 +291,13 @@ class UserSwitchListener
private suspend fun switchUser(user: JellyfinUser) =
supervisorScope {
val uiLanguage = user.uiLanguage
val localeList =
user.uiLanguage?.let { LocaleListCompat.forLanguageTags(uiLanguage) }
?: LocaleListCompat.getEmptyLocaleList()
Timber.i("Switch locale to %s", localeList)
AppCompatDelegate.setApplicationLocales(localeList)
// Check for home settings
launchIO {
homeSettingsService.loadCurrentSettings(user.id)

View file

@ -0,0 +1,188 @@
package com.github.damontecres.wholphin.ui.preferences
import android.content.Context
import android.content.res.XmlResourceParser
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopupContent
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
@HiltViewModel
class LocaleChoiceViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val serverRepository: ServerRepository,
) : ViewModel() {
fun changeLocale(locale: Locale) {
viewModelScope.launchDefault {
serverRepository.current.value?.let {
Timber.i("Updating user's locale to %s", locale.toLanguageTag())
val newUser = it.user.copy(uiLanguage = locale.toLanguageTag())
serverRepository.changeUser(it.server, newUser)
}
}
}
private val _state = MutableStateFlow(LocaleChoiceState())
val state: StateFlow<LocaleChoiceState> = _state
init {
viewModelScope.launchDefault {
serverRepository.currentUser.value?.let {
val availableLocales = extractAvailableLocales()
Timber.v("availableLocales=%s", availableLocales)
_state.update {
it.copy(
loading = LoadingState.Success,
availableLocales = availableLocales,
)
}
}
}
viewModelScope.launchDefault {
serverRepository.currentUser.asFlow().collectLatest { user ->
val userLocale = user?.uiLanguage?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault()
_state.update { it.copy(userLocale = userLocale) }
}
}
}
private fun extractAvailableLocales(): List<Locale> {
val id =
context.resources.getIdentifier(
"_generated_res_locale_config",
"xml",
context.packageName,
)
return if (id != 0) {
try {
val locales = mutableListOf<Locale>()
context.resources.getXml(id).use { parser ->
var eventType: Int = parser.eventType
while (eventType != XmlResourceParser.END_DOCUMENT) {
if (eventType == XmlResourceParser.START_TAG) {
val tagName: String? = parser.name
// Check for a specific tag name
if ("locale" == tagName) {
val attr =
parser.getAttributeValue(
"http://schemas.android.com/apk/res/android",
"name",
)
attr?.let { locales.add(Locale.forLanguageTag(it)) }
}
}
eventType = parser.next()
}
}
locales
} catch (ex: Exception) {
Timber.w(ex, "Exception while parsing generated available locales")
context.assets.locales.map { Locale.forLanguageTag(it) }
}
} else {
context.assets.locales.map { Locale.forLanguageTag(it) }
}
}
}
data class LocaleChoiceState(
val loading: LoadingState = LoadingState.Pending,
val availableLocales: List<Locale> = emptyList(),
val userLocale: Locale = Locale.getDefault(),
)
@Composable
fun LocaleChoiceDialog(
onDismissRequest: () -> Unit,
viewModel: LocaleChoiceViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(),
) {
when (val st = state.loading) {
is LoadingState.Error -> {
ErrorMessage(st)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
}
LoadingState.Success -> {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
val dialogItems =
remember(state.userLocale, state.availableLocales) {
val userLanguage = state.userLocale.toLanguageTag()
state.availableLocales.map { locale ->
val tag = locale.toLanguageTag()
DialogItem(
selected = tag == userLanguage,
leadingContent = {
Box {
SelectedLeadingContent(tag == userLanguage)
}
},
headlineContent = {
Text(locale.getDisplayName(locale))
},
supportingContent = {
Text(locale.getDisplayName(Locale.ENGLISH))
},
dismissOnClick = true,
onClick = {
viewModel.changeLocale(locale)
},
)
}
}
DialogPopupContent(
title = stringResource(R.string.user_interface_language),
dialogItems = dialogItems,
waiting = false,
onDismissRequest = onDismissRequest,
modifier = Modifier.focusRequester(focusRequester),
)
}
}
}
}

View file

@ -83,6 +83,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.Locale
@Composable
fun PreferencesContent(
@ -110,6 +111,7 @@ fun PreferencesContent(
val seerrConnection by viewModel.seerrConnection.collectAsState()
var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) }
var showQuickConnectDialog by remember { mutableStateOf(false) }
var showLocaleChoiceDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.preferenceDataStore.data.collect {
@ -518,6 +520,24 @@ fun PreferencesContent(
)
}
AppPreference.UserInterfaceLanguage -> {
val locale =
remember(currentUser?.uiLanguage) {
currentUser?.uiLanguage?.let { Locale.forLanguageTag(it) }
?: Locale.getDefault()
}
ClickPreference(
title = stringResource(pref.title),
onClick = {
showLocaleChoiceDialog = true
},
modifier = Modifier,
summary = locale.getDisplayName(locale),
onLongClick = null,
interactionSource = interactionSource,
)
}
else -> {
val value = pref.getter.invoke(preferences)
ComposablePreference(
@ -693,6 +713,11 @@ fun PreferencesContent(
}
}
}
if (showLocaleChoiceDialog) {
LocaleChoiceDialog(
onDismissRequest = { showLocaleChoiceDialog = false },
)
}
}
@Composable

View file

@ -0,0 +1 @@
unqualifiedResLocale=en-US

View file

@ -778,6 +778,7 @@
<string name="skip_behavior_summary">For intros, credits, etc</string>
<string name="play_with">Play with</string>
<string name="transcoding">Transcoding</string>
<string name="user_interface_language">User interface language</string>
<array name="ass_subtitle_modes">
<item>@string/ass_subtitle_mode_libass</item>
<item>@string/ass_subtitle_mode_exoplayer</item>