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

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