Support configuring interface language per user (#1406)

## Description
Adds a setting to configure the app's language/locale. It is configured
per user. This settings has no impact on audio or subtitles language
choices.

Only languages Wholphin has translations for can be used. If you would
like to contribute new translations, check out
https://translate.codeberg.org/projects/wholphin/!

Dev notes: This doesn't exactly follow Android's recommendations because
the [guidelines are for
per-app](https://developer.android.com/guide/topics/resources/app-languages)
and not users within the app.

### Related issues
Closes #513
Closes #669

### Testing
- Emulator API 34
- Nvidia shield API 30
- Fire TV OS 7 API 28

## Screenshots
<img width="1280" height="720" alt="language Large"
src="https://github.com/user-attachments/assets/bf1c0962-8a36-4012-aed9-eab564fc1114"
/>

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-05-24 09:55:08 -04:00 • committed by GitHub
parent 625cdcb4a3
commit 903c45dfb8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1501 additions and 418 deletions

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.annotation.StringRes
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
@ -19,6 +20,11 @@ import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions
import com.github.damontecres.wholphin.ui.playback.getTypeFor
import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.util.ResArgStringProvider
import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider
import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.ui.util.StringStringProvider
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
@ -275,11 +281,11 @@ class HomeSettingsService
libraries
.mapIndexed { index, it ->
val parentId = it.itemId
val title = getRecentlyAddedTitle(context, it)
val title = getRecentlyAddedTitle(it)
if (it.collectionType == CollectionType.LIVETV) {
HomeRowConfigDisplay(
id = index,
title = context.getString(R.string.live_tv),
title = ResStringProvider(R.string.live_tv),
config = HomeRowConfig.TvPrograms(),
)
} else {
@ -295,7 +301,7 @@ class HomeSettingsService
listOf(
HomeRowConfigDisplay(
id = includedIds.size + 1,
title = context.getString(R.string.combine_continue_next),
title = ResStringProvider(R.string.combine_continue_next),
config = HomeRowConfig.ContinueWatchingCombined(),
),
)
@ -303,12 +309,12 @@ class HomeSettingsService
listOf(
HomeRowConfigDisplay(
id = includedIds.size + 1,
title = context.getString(R.string.continue_watching),
title = ResStringProvider(R.string.continue_watching),
config = HomeRowConfig.ContinueWatching(),
),
HomeRowConfigDisplay(
id = includedIds.size + 2,
title = context.getString(R.string.next_up),
title = ResStringProvider(R.string.next_up),
config = HomeRowConfig.NextUp(),
),
)
@ -355,7 +361,7 @@ class HomeSettingsService
HomeSectionType.ACTIVE_RECORDINGS -> {
HomeRowConfigDisplay(
id = id++,
title = context.getString(R.string.active_recordings),
title = ResStringProvider(R.string.active_recordings),
config = HomeRowConfig.Recordings(),
)
}
@ -363,7 +369,7 @@ class HomeSettingsService
HomeSectionType.RESUME -> {
HomeRowConfigDisplay(
id = id++,
title = context.getString(R.string.continue_watching),
title = ResStringProvider(R.string.continue_watching),
config = HomeRowConfig.ContinueWatching(),
)
}
@ -371,7 +377,7 @@ class HomeSettingsService
HomeSectionType.NEXT_UP -> {
HomeRowConfigDisplay(
id = id++,
title = context.getString(R.string.next_up),
title = ResStringProvider(R.string.next_up),
config = HomeRowConfig.NextUp(),
)
}
@ -380,7 +386,7 @@ class HomeSettingsService
if (userDto.tvAccess) {
HomeRowConfigDisplay(
id = id++,
title = context.getString(R.string.live_tv),
title = ResStringProvider(R.string.live_tv),
config = HomeRowConfig.TvPrograms(),
)
} else {
@ -413,7 +419,7 @@ class HomeSettingsService
HomeRowConfigDisplay(
id = id++,
title =
context.getString(
ResArgStringProvider(
R.string.recently_added_in,
it.name ?: "",
),
@ -441,7 +447,7 @@ class HomeSettingsService
): HomeRowConfigDisplay =
when (config) {
is HomeRowConfig.ByParent -> {
val name = getItemName(config.parentId) ?: ""
val name = getItemName(null, config.parentId)
HomeRowConfigDisplay(
id,
name,
@ -452,7 +458,7 @@ class HomeSettingsService
is HomeRowConfig.ContinueWatching -> {
HomeRowConfigDisplay(
id,
context.getString(R.string.continue_watching),
ResStringProvider(R.string.continue_watching),
config,
)
}
@ -460,64 +466,64 @@ class HomeSettingsService
is HomeRowConfig.ContinueWatchingCombined -> {
HomeRowConfigDisplay(
id,
context.getString(R.string.combine_continue_next),
ResStringProvider(R.string.combine_continue_next),
config,
)
}
is HomeRowConfig.Genres -> {
val name = getItemName(config.parentId) ?: ""
val title = getItemName(R.string.genres_in, config.parentId)
HomeRowConfigDisplay(
id,
context.getString(R.string.genres_in, name),
title,
config,
)
}
is HomeRowConfig.Studios -> {
val name = getItemName(config.parentId) ?: ""
val title = getItemName(R.string.studios_in, config.parentId)
HomeRowConfigDisplay(
id,
context.getString(R.string.studios_in, name),
title,
config,
)
}
is HomeRowConfig.GetItems -> {
HomeRowConfigDisplay(id, config.name, config)
HomeRowConfigDisplay(id, StringStringProvider(config.name), config)
}
is HomeRowConfig.NextUp -> {
HomeRowConfigDisplay(
id,
context.getString(R.string.next_up),
ResStringProvider(R.string.next_up),
config,
)
}
is HomeRowConfig.RecentlyAdded -> {
val name = getItemName(config.parentId) ?: ""
val title = getItemName(R.string.recently_added_in, config.parentId)
HomeRowConfigDisplay(
id,
context.getString(R.string.recently_added_in, name),
title,
config,
)
}
is HomeRowConfig.RecentlyReleased -> {
val name = getItemName(config.parentId) ?: ""
val title = getItemName(R.string.recently_released_in, config.parentId)
HomeRowConfigDisplay(
id,
context.getString(R.string.recently_released_in, name),
title,
config,
)
}
is HomeRowConfig.Favorite -> {
val name =
context.getString(
ResProviderStringProvider(
R.string.favorite_items,
context.getString(favoriteOptions[config.kind]!!),
ResStringProvider(favoriteOptions[config.kind]!!),
)
HomeRowConfigDisplay(id, name, config)
}
@ -525,7 +531,7 @@ class HomeSettingsService
is HomeRowConfig.Recordings -> {
HomeRowConfigDisplay(
id = id,
title = context.getString(R.string.active_recordings),
title = ResStringProvider(R.string.active_recordings),
config,
)
}
@ -533,7 +539,7 @@ class HomeSettingsService
is HomeRowConfig.TvPrograms -> {
HomeRowConfigDisplay(
id = id,
title = context.getString(R.string.live_tv),
title = ResStringProvider(R.string.live_tv),
config,
)
}
@ -541,29 +547,40 @@ class HomeSettingsService
is HomeRowConfig.TvChannels -> {
HomeRowConfigDisplay(
id = id,
title = context.getString(R.string.channels),
title = ResStringProvider(R.string.channels),
config,
)
}
is HomeRowConfig.Suggestions -> {
val name = getItemName(config.parentId) ?: ""
val title = getItemName(R.string.suggestions_for, config.parentId)
HomeRowConfigDisplay(
id = id,
title = context.getString(R.string.suggestions_for, name),
title = title,
config,
)
}
}
private suspend fun getItemName(itemId: UUID): String? =
private suspend fun getItemName(
@StringRes stringRes: Int?,
itemId: UUID,
default: StringProvider = StringStringProvider(""),
): StringProvider =
try {
api.userLibraryApi
.getItem(itemId = itemId)
.content.name
?.let {
if (stringRes == null) {
StringStringProvider(it)
} else {
ResArgStringProvider(stringRes, it)
}
} ?: default
} catch (ex: Exception) {
Timber.e(ex, "Could not get name for %s", itemId)
context.getString(R.string.unknown)
ResStringProvider(R.string.unknown)
}
/**
@ -590,7 +607,7 @@ class HomeSettingsService
)
Success(
title = context.getString(R.string.continue_watching),
title = ResStringProvider(R.string.continue_watching),
items = resume,
viewOptions = row.viewOptions,
rowType = row,
@ -609,7 +626,7 @@ class HomeSettingsService
)
Success(
title = context.getString(R.string.next_up),
title = ResStringProvider(R.string.next_up),
items = nextUp,
viewOptions = row.viewOptions,
rowType = row,
@ -635,7 +652,7 @@ class HomeSettingsService
)
Success(
title = context.getString(R.string.continue_watching),
title = ResStringProvider(R.string.continue_watching),
items =
latestNextUpService
.buildCombined(
@ -676,8 +693,8 @@ class HomeSettingsService
.firstOrNull { it.itemId == row.parentId }
val title =
library?.name?.let { context.getString(R.string.genres_in, it) }
?: context.getString(R.string.genres)
library?.name?.let { ResArgStringProvider(R.string.genres_in, it) }
?: ResStringProvider(R.string.genres)
val genres =
items.map {
BaseItem(
@ -723,8 +740,8 @@ class HomeSettingsService
libraries
.firstOrNull { it.itemId == row.parentId }
val title =
library?.name?.let { context.getString(R.string.studios_in, it) }
?: context.getString(R.string.studios)
library?.name?.let { ResArgStringProvider(R.string.studios_in, it) }
?: ResStringProvider(R.string.studios)
val studios =
items.map {
val imageUrl =
@ -762,7 +779,7 @@ class HomeSettingsService
val library =
libraries
.firstOrNull { it.itemId == row.parentId }
val title = getRecentlyAddedTitle(context, library)
val title = getRecentlyAddedTitle(library)
val request =
GetLatestMediaRequest(
fields = SlimItemFields,
@ -795,8 +812,8 @@ class HomeSettingsService
?.name
val title =
name?.let {
context.getString(R.string.recently_released_in, it)
} ?: context.getString(R.string.recently_released)
ResArgStringProvider(R.string.recently_released_in, it)
} ?: ResStringProvider(R.string.recently_released)
val request =
GetItemsRequest(
parentId = row.parentId,
@ -853,7 +870,8 @@ class HomeSettingsService
fields = DefaultItemFields,
)
val name = getItemName(row.parentId)
val title =
getItemName(null, row.parentId, ResStringProvider(R.string.collection))
if (usePaging) {
ApiRequestPager(
api,
@ -869,7 +887,7 @@ class HomeSettingsService
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
name ?: context.getString(R.string.collection),
title,
it,
row.viewOptions,
rowType = row,
@ -906,7 +924,7 @@ class HomeSettingsService
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
row.name,
StringStringProvider(row.name),
it,
row.viewOptions,
rowType = row,
@ -916,9 +934,9 @@ class HomeSettingsService
is HomeRowConfig.Favorite -> {
val title =
context.getString(
ResProviderStringProvider(
R.string.favorite_items,
context.getString(favoriteOptions[row.kind]!!),
ResStringProvider(favoriteOptions[row.kind]!!),
)
if (row.kind == BaseItemKind.PERSON) {
val request =
@ -1001,7 +1019,7 @@ class HomeSettingsService
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
context.getString(R.string.active_recordings),
ResStringProvider(R.string.active_recordings),
it,
row.viewOptions,
rowType = row,
@ -1027,7 +1045,7 @@ class HomeSettingsService
.map { BaseItem(it, row.viewOptions.useSeries) }
.let {
Success(
context.getString(R.string.live_tv),
ResStringProvider(R.string.live_tv),
it,
row.viewOptions,
rowType = row,
@ -1057,7 +1075,7 @@ class HomeSettingsService
.toBaseItems(api, row.viewOptions.useSeries)
}.let {
Success(
context.getString(R.string.channels),
ResStringProvider(R.string.channels),
it,
row.viewOptions,
rowType = row,
@ -1070,7 +1088,7 @@ class HomeSettingsService
api.userLibraryApi
.getItem(itemId = row.parentId)
.content
val title = context.getString(R.string.suggestions_for, library.name ?: "")
val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "")
val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
val suggestions =
itemKind?.let {
@ -1111,7 +1129,7 @@ class HomeSettingsService
*/
data class HomeRowConfigDisplay(
val id: Int,
val title: String,
val title: StringProvider,
val config: HomeRowConfig,
)
@ -1154,13 +1172,10 @@ class UnsupportedHomeSettingsVersionException(
val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion")
fun getRecentlyAddedTitle(
context: Context,
library: Library?,
): String =
fun getRecentlyAddedTitle(library: Library?): StringProvider =
if (library?.isRecordingFolder == true) {
context.getString(R.string.recently_recorded)
ResStringProvider(R.string.recently_recorded)
} else {
library?.name?.let { context.getString(R.string.recently_added_in, it) }
?: context.getString(R.string.recently_added)
library?.name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
?: ResStringProvider(R.string.recently_added)
}

View file

@ -1,10 +1,5 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.asFlow
import androidx.lifecycle.lifecycleScope
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest
import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest
@ -12,28 +7,22 @@ import com.github.damontecres.wholphin.api.seerr.model.PublicSettings
import com.github.damontecres.wholphin.api.seerr.model.User
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.supervisorScope
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
@ -140,7 +129,7 @@ class SeerrServerRepository
serverRepository.currentUser.value?.let { jellyfinUser ->
// TODO Need to update server early so that cookies are saved
seerrApi.update(server.url, null)
val userConfig = login(seerrApi.api, authMethod, username, password)
val userConfig = seerrLogin(seerrApi.api, authMethod, username, password)
val user =
SeerrUser(
@ -174,7 +163,7 @@ class SeerrServerRepository
.readTimeout(6.seconds)
.build(),
)
login(api, authMethod, username, passwordOrApiKey)
seerrLogin(api, authMethod, username, passwordOrApiKey)
return LoadingState.Success
}
@ -227,7 +216,7 @@ data class CurrentSeerr(
config.hasPermission(SeerrPermission.REQUEST_4K_TV)
}
private suspend fun login(
suspend fun seerrLogin(
client: SeerrApiClient,
authMethod: SeerrAuthMethod,
username: String?,
@ -259,84 +248,6 @@ private suspend fun login(
}
}
/**
* Listens for JF user switching in the app to also switch the Seerr user/server
*/
@ActivityScoped
class UserSwitchListener
@Inject
constructor(
@param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository,
private val seerrServerRepository: SeerrServerRepository,
private val seerrServerDao: SeerrServerDao,
private val seerrApi: SeerrApi,
private val homeSettingsService: HomeSettingsService,
) {
init {
context as AppCompatActivity
context.lifecycleScope.launchIO {
serverRepository.currentUser.asFlow().collect { user ->
Timber.d("New user")
seerrServerRepository.clear()
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
if (user != null) {
switchUser(user)
}
}
}
}
private suspend fun switchUser(user: JellyfinUser) =
supervisorScope {
// Check for home settings
launchIO {
homeSettingsService.loadCurrentSettings(user.id)
}
if (BuildConfig.DISCOVER_ENABLED) {
// Check for seerr server
launchIO {
seerrServerDao
.getUsersByJellyfinUser(user.rowId)
.lastOrNull()
?.let { seerrUser ->
val server =
seerrServerDao.getServer(seerrUser.serverId)?.server
if (server != null) {
Timber.i("Found a seerr user & server")
try {
seerrApi.update(server.url, seerrUser.credential)
val userConfig =
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
login(
seerrApi.api,
seerrUser.authMethod,
seerrUser.username,
seerrUser.password,
)
} else {
seerrApi.api.usersApi.authMeGet()
}
seerrServerRepository.set(
server,
seerrUser,
userConfig,
)
} catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.error(server, seerrUser, ex)
}
}
}
}
}
}
}
fun CurrentSeerr?.imageUrlBuilder(
imageType: ImageType,
path: String?,

View file

@ -0,0 +1,110 @@
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
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
/**
* Listens for JF user switching in the app to also switch other settings like Seerr user/server
*/
@ActivityScoped
class UserSwitchListener
@Inject
constructor(
@param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository,
private val seerrServerRepository: SeerrServerRepository,
private val seerrServerDao: SeerrServerDao,
private val seerrApi: SeerrApi,
private val homeSettingsService: HomeSettingsService,
) {
init {
context as AppCompatActivity
context.lifecycleScope.launchDefault {
serverRepository.currentUser.asFlow().collect { user ->
Timber.d("New user")
seerrServerRepository.clear()
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
if (user != null) {
switchUser(user)
}
}
}
}
private suspend fun switchUser(user: JellyfinUser) =
supervisorScope {
// Switch the locale to either the user's choice or the system default (empty)
val localeList =
user.uiLanguage?.let { LocaleListCompat.forLanguageTags(it) }
?: LocaleListCompat.getEmptyLocaleList()
Timber.i("Switching locale to %s", localeList)
withContext(Dispatchers.Main) {
AppCompatDelegate.setApplicationLocales(localeList)
}
// Check for home settings
launchIO {
homeSettingsService.loadCurrentSettings(user.id)
}
if (BuildConfig.DISCOVER_ENABLED) {
// Check for seerr server
launchIO {
seerrServerDao
.getUsersByJellyfinUser(user.rowId)
.lastOrNull()
?.let { seerrUser ->
val server =
seerrServerDao.getServer(seerrUser.serverId)?.server
if (server != null) {
Timber.i("Found a seerr user & server")
try {
seerrApi.update(server.url, seerrUser.credential)
val userConfig =
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
seerrLogin(
seerrApi.api,
seerrUser.authMethod,
seerrUser.username,
seerrUser.password,
)
} else {
seerrApi.api.usersApi.authMeGet()
}
seerrServerRepository.set(
server,
seerrUser,
userConfig,
)
} catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.error(server, seerrUser, ex)
}
}
}
}
}
}
}