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:
parent
077b7e4570
commit
3f60fa7550
26 changed files with 1751 additions and 48 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ fun NowPlayingPage(
|
|||
.collectAsState(
|
||||
UserPreferences(
|
||||
AppPreferences.getDefaultInstance(),
|
||||
null,
|
||||
),
|
||||
).value.appPreferences
|
||||
val musicPrefs = preferences.musicPreferences
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ sealed class Destination(
|
|||
val hdr: Boolean,
|
||||
) : Destination(true)
|
||||
|
||||
@Serializable
|
||||
data object UserAppPreferences : Destination(true)
|
||||
|
||||
@Serializable
|
||||
data object Search : Destination()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue