Preferences updates

This commit is contained in:
Damontecres 2025-09-30 17:14:08 -04:00
parent 54bdb25d49
commit 60de501851
No known key found for this signature in database
25 changed files with 1544 additions and 56 deletions

View file

@ -49,6 +49,12 @@ import com.github.damontecres.dolphin.ui.FontAwesome
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
data class DialogParams(
val fromLongClick: Boolean,
val title: String,
val items: List<DialogItem>,
)
sealed interface DialogItemEntry
data object DialogItemDivider : DialogItemEntry

View file

@ -0,0 +1,110 @@
package com.github.damontecres.dolphin.ui.components
import android.view.KeyEvent
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.dolphin.ui.handleDPadKeyEvents
@Composable
fun SliderBar(
value: Long,
min: Long,
max: Long,
onChange: (Long) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
interval: Int = 1,
color: Color = MaterialTheme.colorScheme.border,
) {
val isFocused by interactionSource.collectIsFocusedAsState()
val animatedIndicatorHeight by animateDpAsState(
targetValue = 6.dp.times((if (isFocused) 2f else 1f)),
)
var currentValue by remember(value) { mutableLongStateOf(value) }
val percent = currentValue.toFloat() / (max - min)
val handleSeekEventModifier =
Modifier.handleDPadKeyEvents(
triggerOnAction = KeyEvent.ACTION_DOWN,
onCenter = {
onChange(currentValue)
},
onLeft = {
if (currentValue <= min) {
currentValue = max
} else {
currentValue = (currentValue - interval).coerceAtLeast(min)
}
onChange(currentValue)
},
onRight = {
if (currentValue >= max) {
currentValue = min
} else {
currentValue = (currentValue + interval).coerceAtMost(max)
}
onChange(currentValue)
},
)
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Canvas(
modifier =
Modifier
.fillMaxWidth()
.height(animatedIndicatorHeight)
.padding(horizontal = 4.dp)
.then(handleSeekEventModifier)
.focusable(interactionSource = interactionSource),
onDraw = {
val yOffset = size.height.div(2)
drawLine(
color = color.copy(alpha = 0.15f),
start = Offset(x = 0f, y = yOffset),
end = Offset(x = size.width, y = yOffset),
strokeWidth = size.height,
cap = StrokeCap.Round,
)
drawLine(
color = color,
start = Offset(x = 0f, y = yOffset),
end =
Offset(
// x = size.width.times(if (isSelected) seekProgress else progress),
x = size.width.times(percent),
y = yOffset,
),
strokeWidth = size.height,
cap = StrokeCap.Round,
)
drawCircle(
color = Color.White,
radius = size.height + 2,
center = Offset(x = size.width.times(percent), y = yOffset),
)
},
)
}
}

View file

@ -89,7 +89,7 @@ fun CardGrid(
if (DEBUG) {
Timber.d(
"StashGrid: hasRun=$hasRequestFocusRun, requestFocus=$requestFocus, initialPosition=$initialPosition, focusedIndex=$focusedIndex",
"Grid: hasRun=$hasRequestFocusRun, requestFocus=$requestFocus, initialPosition=$initialPosition, focusedIndex=$focusedIndex",
)
}

View file

@ -13,6 +13,8 @@ import com.github.damontecres.dolphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview
import com.github.damontecres.dolphin.ui.main.MainPage
import com.github.damontecres.dolphin.ui.playback.PlaybackContent
import com.github.damontecres.dolphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.dolphin.ui.preferences.PreferencesPage
import com.github.damontecres.dolphin.ui.setup.SwitchServerContent
import com.github.damontecres.dolphin.ui.setup.SwitchUserContent
import org.jellyfin.sdk.model.api.BaseItemKind
@ -46,6 +48,14 @@ fun DestinationContent(
Destination.ServerList -> SwitchServerContent(navigationManager, modifier)
Destination.UserList -> SwitchUserContent(navigationManager, modifier)
Destination.Settings ->
PreferencesPage(
navigationManager,
preferences.appPreferences,
PreferenceScreenOption.BASIC,
modifier,
)
is Destination.MediaItem ->
when (destination.type) {
BaseItemKind.SERIES ->
@ -104,7 +114,6 @@ fun DestinationContent(
}
Destination.Search -> TODO()
Destination.Settings -> TODO()
Destination.Setup -> TODO()
}
}

View file

@ -48,8 +48,6 @@ import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.stashapp.ui.components.playback.SkipIndicator
import com.github.damontecres.stashapp.ui.components.playback.rememberSeekBarState
import org.jellyfin.sdk.model.api.DeviceProfile
import kotlin.time.Duration.Companion.milliseconds

View file

@ -59,7 +59,6 @@ import androidx.tv.material3.Text
import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.ui.AppColors
import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.stashapp.ui.components.playback.SeekBarImpl
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

View file

@ -1,4 +1,4 @@
package com.github.damontecres.stashapp.ui.components.playback
package com.github.damontecres.dolphin.ui.playback
/*
* Modified from https://github.com/android/tv-samples
@ -42,7 +42,6 @@ import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.dolphin.ui.handleDPadKeyEvents
import com.github.damontecres.dolphin.ui.playback.ControllerViewState
@Composable
fun SeekBarImpl(

View file

@ -1,4 +1,4 @@
package com.github.damontecres.stashapp.ui.components.playback
package com.github.damontecres.dolphin.ui.playback
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect

View file

@ -1,4 +1,4 @@
package com.github.damontecres.stashapp.ui.components.playback
package com.github.damontecres.dolphin.ui.playback
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween

View file

@ -0,0 +1,305 @@
package com.github.damontecres.dolphin.ui.preferences
import android.content.Context
import androidx.annotation.ArrayRes
import androidx.annotation.StringRes
import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.preferences.AppPreferences
import com.github.damontecres.dolphin.preferences.updatePlaybackPreferences
import com.github.damontecres.dolphin.ui.nav.Destination
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* A preference that can be stored in the shared preferences.
*
* @param T The type of the preference value.
*/
sealed interface AppPreference<T> {
/**
* String resource ID for the title of the preference
*/
@get:StringRes
val title: Int
/**
* Default value for the preference for UI purposes
*/
val defaultValue: T
/**
* A function that gets the value from the [AppPreferences] object for UI purposes. This means
* that it should return the value that is displayed in the UI, which isn't necessarily the raw value
*/
val getter: (prefs: AppPreferences) -> T
/**
* A function that sets the value in the [AppPreferences] object from the UI. It should convert the value if needed
*/
val setter: (prefs: AppPreferences, value: T) -> AppPreferences
fun summary(
context: Context,
value: T?,
): String? = null
fun validate(value: T): PreferenceValidation = PreferenceValidation.Valid
companion object {
val SkipForward =
AppSliderPreference(
title = R.string.skip_forward_preference,
defaultValue = 30,
min = 10,
max = 5.minutes.inWholeSeconds,
interval = 5,
getter = {
it.playbackPreferences.skipForwardMs
.milliseconds.inWholeSeconds
},
setter = { prefs, value ->
prefs.updatePlaybackPreferences {
skipForwardMs = value.seconds.inWholeMilliseconds
}
},
summarizer = { value ->
if (value != null) {
"$value seconds"
} else {
null
}
},
)
val SkipBack =
AppSliderPreference(
title = R.string.skip_back_preference,
defaultValue = 10,
min = 5,
max = 5.minutes.inWholeSeconds,
interval = 5,
getter = {
it.playbackPreferences.skipBackMs
.milliseconds.inWholeSeconds
},
setter = { prefs, value ->
prefs.updatePlaybackPreferences {
skipBackMs = value.seconds.inWholeMilliseconds
}
},
summarizer = { value ->
if (value != null) {
"$value seconds"
} else {
null
}
},
)
// val GridJumpButtons =
// AppSwitchPreference(
// title = R.string.show_grid_jump_buttons,
// defaultValue = true,
// getter = { it.interfacePreferences.showGridJumpButtons },
// setter = { prefs, value ->
// prefs.updateInterfacePreferences { showGridJumpButtons = value }
// },
// summaryOn = R.string.enabled,
// summaryOff = R.string.disabled,
// )
// val ShowGridFooter =
// AppSwitchPreference(
// title = R.string.grid_position_footer,
// defaultValue = true,
// getter = { it.interfacePreferences.showPositionFooter },
// setter = { prefs, value ->
// prefs.updateInterfacePreferences { showPositionFooter = value }
// },
// summaryOn = R.string.show,
// summaryOff = R.string.hide,
// )
val ControllerTimeout =
AppSliderPreference(
title = R.string.hide_controller_timeout,
defaultValue = 5000,
min = 500,
max = 15.seconds.inWholeMilliseconds,
interval = 100,
getter = { it.playbackPreferences.controllerTimeoutMs },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { controllerTimeoutMs = value }
},
summarizer = { value -> value?.let { "${value / 1000.0} seconds" } },
)
val SeekBarSteps =
AppSliderPreference(
title = R.string.seek_bar_steps,
defaultValue = 16,
min = 4,
max = 64,
interval = 1,
getter = { it.playbackPreferences.seekBarSteps.toLong() },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { seekBarSteps = value.toInt() }
},
summarizer = { value -> value?.toString() },
)
// val PlaybackDebugInfo =
// AppSwitchPreference(
// title = R.string.playback_debug_info,
// prefKey = R.string.pref_key_show_playback_debug_info,
// defaultValue = false,
// getter = { it.playbackPreferences.showDebugInfo },
// setter = { prefs, value ->
// prefs.updatePlaybackPreferences { showDebugInfo = value }
// },
// summaryOn = R.string.show,
// summaryOff = R.string.hide,
// )
// val AutoCheckForUpdates =
// AppSwitchPreference(
// title = R.string.check_for_updates,
// prefKey = R.string.pref_key_auto_check_updates,
// defaultValue = true,
// getter = { it.updatePreferences.checkForUpdates },
// setter = { prefs, value ->
// prefs.updateUpdatePreferences { checkForUpdates = value }
// },
// summaryOn = R.string.enabled,
// summaryOff = R.string.disabled,
// )
// val UpdateUrl =
// AppStringPreference(
// title = R.string.update_url,
// defaultValue = "",
// getter = { it.updatePreferences.updateUrl },
// setter = { prefs, value ->
// prefs.updateUpdatePreferences { updateUrl = value }
// },
// summary = R.string.update_url_summary,
// )
// val OssLicenseInfo =
// AppDestinationPreference(
// title = R.string.oss_license_info,
// destination = Destination.LicenseInfo,
// )
}
}
data class AppSwitchPreference(
@get:StringRes override val title: Int,
override val defaultValue: Boolean,
override val getter: (prefs: AppPreferences) -> Boolean,
override val setter: (prefs: AppPreferences, value: Boolean) -> AppPreferences,
val validator: (value: Boolean) -> PreferenceValidation = { PreferenceValidation.Valid },
@param:StringRes val summary: Int? = null,
@param:StringRes val summaryOn: Int? = null,
@param:StringRes val summaryOff: Int? = null,
) : AppPreference<Boolean> {
override fun summary(
context: Context,
value: Boolean?,
): String? =
when {
summaryOn != null && value == true -> context.getString(summaryOn)
summaryOff != null && value == false -> context.getString(summaryOff)
else -> summary?.let { context.getString(summary) }
}
}
open class AppStringPreference(
@param:StringRes override val title: Int,
override val defaultValue: String,
override val getter: (AppPreferences) -> String,
override val setter: (AppPreferences, String) -> AppPreferences,
@param:StringRes val summary: Int?,
) : AppPreference<String> {
override fun summary(
context: Context,
value: String?,
): String? = summary?.let { context.getString(it) } ?: value
}
data class AppChoicePreference<T>(
@param:StringRes override val title: Int,
override val defaultValue: T,
@param:ArrayRes val displayValues: Int,
val indexToValue: (index: Int) -> T,
val valueToIndex: (T) -> Int,
override val getter: (prefs: AppPreferences) -> T,
override val setter: (prefs: AppPreferences, value: T) -> AppPreferences,
@param:StringRes val summary: Int? = null,
) : AppPreference<T>
data class AppMultiChoicePreference<T>(
@param:StringRes override val title: Int,
override val defaultValue: List<T>,
val allValues: List<T>,
@param:ArrayRes val displayValues: Int,
override val getter: (prefs: AppPreferences) -> List<T>,
override val setter: (prefs: AppPreferences, value: List<T>) -> AppPreferences,
@param:StringRes val summary: Int? = null,
val toSharedPrefs: (T) -> String,
val fromSharedPrefs: (String) -> T?,
) : AppPreference<List<T>>
data class AppClickablePreference(
@param:StringRes override val title: Int,
override val defaultValue: Unit = Unit,
override val getter: (prefs: AppPreferences) -> Unit = { },
override val setter: (prefs: AppPreferences, value: Unit) -> AppPreferences = { prefs, _ -> prefs },
@param:StringRes val summary: Int? = null,
) : AppPreference<Unit> {
override fun summary(
context: Context,
value: Unit?,
): String? = summary?.let { context.getString(it) }
}
data class AppDestinationPreference(
@param:StringRes override val title: Int,
override val defaultValue: Unit = Unit,
override val getter: (prefs: AppPreferences) -> Unit = { },
override val setter: (prefs: AppPreferences, value: Unit) -> AppPreferences = { prefs, _ -> prefs },
@param:StringRes val summary: Int? = null,
val destination: Destination,
) : AppPreference<Unit> {
override fun summary(
context: Context,
value: Unit?,
): String? = summary?.let { context.getString(it) }
}
class AppSliderPreference(
@param:StringRes override val title: Int,
override val defaultValue: Long,
/**
* Minimum value for the slider. Similar to [defaultValue], this is for UI purposes only
*/
val min: Long = 0,
/**
* Max value for the slider. Similar to [defaultValue], this is for UI purposes only
*/
val max: Long = 100,
val interval: Int = 1,
override val getter: (prefs: AppPreferences) -> Long,
override val setter: (prefs: AppPreferences, value: Long) -> AppPreferences,
@param:StringRes val summary: Int? = null,
val summarizer: ((Long?) -> String?)? = null,
) : AppPreference<Long> {
override fun summary(
context: Context,
value: Long?,
): String? =
summarizer?.invoke(value)
?: summary?.let { context.getString(it) }
?: value?.toString()
}

View file

@ -0,0 +1,31 @@
package com.github.damontecres.dolphin.ui.preferences
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.tv.material3.ListItem
@Composable
fun ClickPreference(
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
onLongClick: (() -> Unit)? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
ListItem(
selected = false,
onClick = onClick,
onLongClick = onLongClick,
headlineContent = {
PreferenceTitle(title)
},
supportingContent = {
PreferenceSummary(summary)
},
interactionSource = interactionSource,
modifier = modifier,
)
}

View file

@ -0,0 +1,390 @@
package com.github.damontecres.dolphin.ui.preferences
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.ui.components.DialogItem
import com.github.damontecres.dolphin.ui.components.DialogParams
import com.github.damontecres.dolphin.ui.components.DialogPopup
import com.github.damontecres.dolphin.ui.components.EditTextBox
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import kotlinx.coroutines.launch
@Suppress("UNCHECKED_CAST")
@Composable
fun <T> ComposablePreference(
navigationManager: NavigationManager,
preference: AppPreference<T>,
value: T?,
onValueChange: (T) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var dialogParams by remember { mutableStateOf<DialogParams?>(null) }
var showStringDialog by remember { mutableStateOf<StringInput?>(null) }
val title = stringResource(preference.title)
val onClick: () -> Unit = {
scope.launch {
when (preference) {
else -> {}
}
}
}
val onLongClick: () -> Unit = {
scope.launch {
when (preference) {
else -> null
}
}
}
when (preference) {
is AppDestinationPreference ->
ClickPreference(
title = title,
onClick = {
navigationManager.navigateTo(preference.destination)
},
summary = preference.summary(context, value),
interactionSource = interactionSource,
modifier = modifier,
)
is AppClickablePreference ->
ClickPreference(
title = title,
onClick = onClick,
onLongClick = onLongClick,
summary = preference.summary(context, value),
interactionSource = interactionSource,
modifier = modifier,
)
is AppSwitchPreference ->
SwitchPreference(
title = title,
value = value as Boolean,
onClick = { onValueChange.invoke(!value as T) },
summary = preference.summary(context, value),
interactionSource = interactionSource,
modifier = modifier,
)
is AppStringPreference ->
ClickPreference(
title = title,
onClick = {
showStringDialog =
StringInput(
title = title,
value = value as String?,
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
// keyboardType =
// if (preference == AppPreference.UpdateUrl) {
// KeyboardType.Uri
// } else {
// KeyboardType.Unspecified
// },
imeAction = ImeAction.Done,
),
onSubmit = { input ->
onValueChange.invoke(input as T)
showStringDialog = null
},
)
},
summary =
preference.summary(context, value)
?: preference.summary?.let { stringResource(it) },
interactionSource = interactionSource,
modifier = modifier,
)
is AppChoicePreference -> {
val values = stringArrayResource(preference.displayValues).toList()
val summary =
preference.summary?.let { stringResource(it) }
?: preference.summary(context, value)
?: preference
.valueToIndex(value as T)
.let { values[it] }
val selectedIndex = remember(value) { preference.valueToIndex.invoke(value as T) }
ClickPreference(
title = title,
summary = summary,
onClick = {
dialogParams =
DialogParams(
title = title,
fromLongClick = false,
items =
values.mapIndexed { index, it ->
if (index == selectedIndex) {
DialogItem(
text = it,
icon = Icons.Default.Done,
onClick = {
onValueChange(preference.indexToValue(index))
dialogParams = null
},
)
} else {
DialogItem(
text = it,
onClick = {
onValueChange(preference.indexToValue(index))
dialogParams = null
},
)
}
},
)
},
interactionSource = interactionSource,
modifier = modifier,
)
}
is AppMultiChoicePreference<*> -> {
val values = stringArrayResource(preference.displayValues).toList()
val summary =
preference.summary?.let { stringResource(it) }
?: preference.summary(context, value)
val selectedValues =
remember {
val list = mutableStateSetOf<Any>()
list.addAll(value as List<Any>)
list
}
val onClick = { item: Any ->
if (selectedValues.contains(item)) {
selectedValues.remove(item)
} else {
selectedValues.add(item)
}
onValueChange.invoke(selectedValues.toList() as T)
}
ClickPreference(
title = title,
summary = summary,
onClick = {
dialogParams =
DialogParams(
title = title,
fromLongClick = false,
items =
values.mapIndexed { index, it ->
val item = preference.allValues[index]!!
DialogItem(
headlineContent = { Text(it) },
trailingContent = {
Switch(
checked = selectedValues.contains(item),
onCheckedChange = {
onClick.invoke(item)
},
)
},
onClick = {
onClick.invoke(item)
},
)
},
)
},
interactionSource = interactionSource,
modifier = modifier,
)
}
is AppSliderPreference -> {
val summary =
preference.summary(context, value)
?: preference.summary?.let { stringResource(it) }
SliderPreference(
preference = preference,
title = title,
summary = summary,
value = value as Long,
onChange = { onValueChange(it as T) },
summaryBelow = false,
interactionSource = interactionSource,
modifier = modifier,
)
}
}
val dialogBackgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
AnimatedVisibility(dialogParams != null) {
dialogParams?.let {
DialogPopup(
showDialog = true,
title = it.title,
dialogItems = it.items,
onDismissRequest = { dialogParams = null },
waitToLoad = false,
dismissOnClick = false,
)
}
}
AnimatedVisibility(showStringDialog != null) {
showStringDialog?.let {
var mutableValue by remember { mutableStateOf(it.value ?: "") }
val onDone = {
it.onSubmit.invoke(mutableValue)
}
Dialog(
onDismissRequest = { showStringDialog = null },
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.padding(16.dp)
.background(
color = dialogBackgroundColor,
shape = RoundedCornerShape(8.dp),
),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(16.dp),
) {
Text(
text = it.title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier,
)
EditTextBox(
value = mutableValue,
onValueChange = { mutableValue = it },
keyboardOptions = it.keyboardOptions.copy(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = { onDone.invoke() },
),
leadingIcon = null,
isInputValid = { true },
modifier = Modifier.fillMaxWidth(),
)
Row(
horizontalArrangement = Arrangement.SpaceAround,
modifier = Modifier.fillMaxWidth(),
) {
Button(
onClick = { showStringDialog = null },
modifier = Modifier,
) {
Text(
text = stringResource(R.string.cancel),
)
}
Button(
onClick = onDone,
modifier = Modifier,
) {
Text(
text = stringResource(R.string.save),
)
}
}
}
}
}
}
}
}
val PreferenceTitleStyle: TextStyle
@Composable @ReadOnlyComposable
get() = MaterialTheme.typography.titleSmall
val PreferenceSummaryStyle: TextStyle
@Composable @ReadOnlyComposable
get() = MaterialTheme.typography.bodySmall
@Composable
fun PreferenceTitle(
title: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
) {
Text(
text = title,
style = PreferenceTitleStyle,
color = color,
modifier = modifier,
)
}
@Composable
fun PreferenceSummary(
summary: String?,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
) {
summary?.let {
Text(
text = it,
style = PreferenceSummaryStyle,
color = color,
modifier = modifier,
)
}
}
private data class StringInput(
val title: String,
val value: String?,
val keyboardOptions: KeyboardOptions,
val onSubmit: (String) -> Unit,
)

View file

@ -0,0 +1,35 @@
package com.github.damontecres.dolphin.ui.preferences
import androidx.annotation.StringRes
import kotlinx.serialization.Serializable
/**
* A group of preferences
*/
data class PreferenceGroup(
@param:StringRes val title: Int,
val preferences: List<AppPreference<out Any?>>,
)
/**
* Results when validating a preference value.
*/
sealed interface PreferenceValidation {
data object Valid : PreferenceValidation
data class Invalid(
val message: String,
) : PreferenceValidation
}
@Serializable
enum class PreferenceScreenOption {
BASIC,
ADVANCED,
USER_INTERFACE,
;
companion object {
fun fromString(name: String?) = entries.firstOrNull { it.name == name } ?: BASIC
}
}

View file

@ -0,0 +1,343 @@
package com.github.damontecres.dolphin.ui.preferences
import android.widget.Toast
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.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.platform.LocalContext
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 com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.preferences.AppPreferences
import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.ui.playOnClickSound
import com.github.damontecres.dolphin.ui.playSoundOnFocus
import com.github.damontecres.dolphin.ui.tryRequestFocus
import kotlinx.coroutines.launch
val basicPreferences =
listOf(
PreferenceGroup(
title = R.string.basic_interface,
preferences =
listOf(
AppPreference.SkipForward,
AppPreference.SkipBack,
AppPreference.ControllerTimeout,
AppPreference.SeekBarSteps,
),
),
)
val uiPreferences = listOf<PreferenceGroup>()
val advancedPreferences = listOf<PreferenceGroup>()
data class Release(
val version: String,
)
@Composable
fun PreferencesContent(
navigationManager: NavigationManager,
initialPreferences: AppPreferences,
preferenceScreenOption: PreferenceScreenOption,
modifier: Modifier = Modifier,
viewModel: PreferencesViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) }
val state = rememberLazyListState()
var preferences by remember { mutableStateOf(initialPreferences) }
LaunchedEffect(Unit) {
viewModel.preferenceDataStore.data.collect {
preferences = it
}
}
val movementSounds = true
val installedVersion = remember { "v1.0.0" }
var updateVersion by remember { mutableStateOf<Release?>(null) }
val updateAvailable = false
// remember(updateVersion) { updateVersion?.version?.isGreaterThan(installedVersion) == true }
// if (preferences.updatePreferences.checkForUpdates) {
// LaunchedEffect(Unit) {
// updateVersion =
// UpdateChecker.getLatestRelease(context, preferences.updatePreferences.updateUrl)
// }
// }
val prefList =
when (preferenceScreenOption) {
PreferenceScreenOption.BASIC -> basicPreferences
PreferenceScreenOption.ADVANCED -> advancedPreferences
PreferenceScreenOption.USER_INTERFACE -> uiPreferences
}
val screenTitle =
when (preferenceScreenOption) {
PreferenceScreenOption.BASIC -> "Preferences"
PreferenceScreenOption.ADVANCED -> "Advanced Preferences"
PreferenceScreenOption.USER_INTERFACE -> "User Interface Preferences"
}
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()
}
LazyColumn(
state = state,
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(0.dp),
contentPadding = PaddingValues(16.dp),
modifier = Modifier.background(MaterialTheme.colorScheme.secondaryContainer),
) {
stickyHeader {
Text(
text = screenTitle,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
)
}
prefList.forEachIndexed { groupIndex, group ->
item {
Text(
text = stringResource(group.title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.border,
textAlign = TextAlign.Start,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
)
}
if (updateAvailable &&
groupIndex == 0 &&
preferenceScreenOption == PreferenceScreenOption.BASIC
) {
item {
val updateFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (focusedIndex.first == 0 && focusedIndex.second == 0) {
// Only re-focus if the user hasn't moved
updateFocusRequester.tryRequestFocus()
}
}
ClickPreference(
title = stringResource(R.string.install_update),
onClick = {
if (movementSounds) playOnClickSound(context)
updateVersion?.let {
// navigationManager.navigateTo(Destination.UpdateApp(it))
}
},
summary = updateVersion?.version?.toString(),
modifier =
Modifier
.focusRequester(updateFocusRequester)
.playSoundOnFocus(movementSounds),
)
}
}
group.preferences.forEachIndexed { prefIndex, pref ->
pref as AppPreference<Any>
item {
val interactionSource = remember { MutableInteractionSource() }
val focused = interactionSource.collectIsFocusedAsState().value
LaunchedEffect(focused) {
if (focused) {
focusedIndex = Pair(groupIndex, prefIndex)
if (movementSounds) playOnClickSound(context)
}
}
when (pref) {
// AppPreference.InstalledVersion -> {
// var clickCount by remember { mutableIntStateOf(0) }
// ClickPreference(
// title = stringResource(R.string.installed_version),
// onClick = {
// if (movementSounds) playOnClickSound(context)
// if (clickCount++ >= 2) {
// clickCount = 0
// // navigationManager.navigateTo(Destination.Debug)
// }
// },
// summary = installedVersion.toString(),
// interactionSource = interactionSource,
// modifier =
// Modifier
// .ifElse(
// groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
// Modifier.focusRequester(focusRequester),
// ),
// )
// }
// AppPreference.Update -> {
// ClickPreference(
// title =
// if (updateVersion != null && updateAvailable) {
// stringResource(R.string.install_update)
// } else if (!preferences.updatePreferences.checkForUpdates && updateVersion == null) {
// stringResource(R.string.check_for_updates)
// } else {
// stringResource(R.string.no_update_available)
// },
// onClick = {
// if (movementSounds) playOnClickSound(context)
// if (updateVersion != null && updateAvailable) {
// updateVersion?.let {
// navigationManager.navigate(
// Destination.UpdateApp(it),
// )
// }
// } else {
// scope.launch {
// updateVersion =
// UpdateChecker.getLatestRelease(
// context,
// preferences.updatePreferences.updateUrl,
// )
// }
// }
// },
// onLongClick = {
// if (movementSounds) playOnClickSound(context)
// updateVersion?.let {
// navigationManager.navigate(
// Destination.UpdateApp(it),
// )
// }
// },
// summary =
// if (updateAvailable) {
// updateVersion?.version?.toString()
// } else {
// null
// },
// interactionSource = interactionSource,
// modifier =
// Modifier
// .ifElse(
// groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
// Modifier.focusRequester(focusRequester),
// ),
// )
// }
else -> {
val value = pref.getter.invoke(preferences)
ComposablePreference(
navigationManager = navigationManager,
preference = pref,
value = value,
onValueChange = { newValue ->
val validation = pref.validate(newValue)
when (validation) {
is PreferenceValidation.Invalid -> {
// TODO?
Toast
.makeText(
context,
validation.message,
Toast.LENGTH_SHORT,
).show()
}
PreferenceValidation.Valid -> {
scope.launch {
preferences =
viewModel.preferenceDataStore.updateData { prefs ->
pref.setter(prefs, newValue)
}
}
}
}
},
interactionSource = interactionSource,
modifier =
Modifier
.ifElse(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
Modifier.focusRequester(focusRequester),
),
)
}
}
}
}
}
}
}
}
@Composable
fun PreferencesPage(
navigationManager: NavigationManager,
initialPreferences: AppPreferences,
preferenceScreenOption: PreferenceScreenOption,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.background(MaterialTheme.colorScheme.background),
) {
PreferencesContent(
navigationManager,
initialPreferences,
preferenceScreenOption,
Modifier
.fillMaxWidth(.4f)
.fillMaxHeight()
.align(Alignment.TopEnd),
)
}
}

View file

@ -0,0 +1,14 @@
package com.github.damontecres.dolphin.ui.preferences
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import com.github.damontecres.dolphin.preferences.AppPreferences
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class PreferencesViewModel
@Inject
constructor(
val preferenceDataStore: DataStore<AppPreferences>,
) : ViewModel()

View file

@ -0,0 +1,95 @@
package com.github.damontecres.dolphin.ui.preferences
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.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import com.github.damontecres.dolphin.ui.components.SliderBar
@Composable
fun SliderPreference(
preference: AppSliderPreference,
title: String,
summary: String?,
value: Long,
onChange: (Long) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
summaryBelow: Boolean = false,
heightAdjustment: Dp = 0.dp,
additionalSummary: @Composable (ColumnScope.() -> Unit)? = null,
) {
val focused = interactionSource.collectIsFocusedAsState().value
val background =
if (focused) {
MaterialTheme.colorScheme.onBackground
} else {
Color.Unspecified
}
val contentColor =
if (focused) {
Color.Unspecified
} else {
MaterialTheme.colorScheme.onSurface
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
// .height(80.dp) // not dense
.height(72.dp + heightAdjustment) // dense
.fillMaxWidth()
.background(background, shape = RoundedCornerShape(8.dp))
.padding(PaddingValues(horizontal = 12.dp, vertical = 10.dp)), // dense,
) {
PreferenceTitle(title, color = contentColor)
ProvideTextStyle(PreferenceSummaryStyle.copy(color = contentColor)) {
additionalSummary?.invoke(this)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize(),
) {
SliderBar(
value = value,
min = preference.min,
max = preference.max,
interval = preference.interval,
onChange = onChange,
color = MaterialTheme.colorScheme.border,
interactionSource = interactionSource,
modifier = Modifier.weight(1f),
)
if (!summaryBelow) {
PreferenceSummary(summary, color = contentColor)
}
}
if (summaryBelow) {
PreferenceSummary(summary, color = contentColor)
}
}
}

View file

@ -0,0 +1,63 @@
package com.github.damontecres.dolphin.ui.preferences
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.tv.material3.ListItem
import androidx.tv.material3.Switch
import androidx.tv.material3.SwitchDefaults
@Composable
fun SwitchPreference(
title: String,
value: Boolean,
onClick: () -> Unit,
summaryOn: String?,
summaryOff: String?,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = SwitchPreference(
title = title,
value = value,
onClick = onClick,
modifier = modifier,
summary = if (value) summaryOn else summaryOff,
onLongClick = onLongClick,
interactionSource = interactionSource,
)
@Composable
fun SwitchPreference(
title: String,
value: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
onLongClick: (() -> Unit)? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
ListItem(
selected = false,
onClick = onClick,
onLongClick = onLongClick,
headlineContent = {
PreferenceTitle(title)
},
supportingContent = {
PreferenceSummary(summary)
},
trailingContent = {
Switch(
checked = value,
onCheckedChange = { onClick.invoke() },
colors =
SwitchDefaults
.colors(),
)
},
interactionSource = interactionSource,
modifier = modifier,
)
}