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

@ -24,6 +24,8 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.dolphin.data.ServerRepository
import com.github.damontecres.dolphin.preferences.AppPreferences
import com.github.damontecres.dolphin.preferences.DefaultUserConfiguration
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.CoilConfig
import com.github.damontecres.dolphin.ui.nav.ApplicationContent
@ -43,7 +45,7 @@ class MainActivity : AppCompatActivity() {
lateinit var serverRepository: ServerRepository
@Inject
lateinit var userPreferencesDataStore: DataStore<UserPreferences>
lateinit var userPreferencesDataStore: DataStore<AppPreferences>
@Inject
lateinit var okHttpClient: OkHttpClient
@ -61,23 +63,31 @@ class MainActivity : AppCompatActivity() {
CoilConfig(serverRepository, okHttpClient, false)
var isRestoringSession by remember { mutableStateOf(true) }
val preferences by userPreferencesDataStore.data.collectAsState(null)
preferences?.let { preferences ->
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
appPreferences?.let { appPreferences ->
LaunchedEffect(Unit) {
if (preferences.currentServerId.isNotBlank() && preferences.currentUserId.isNotBlank()) {
if (appPreferences.currentServerId.isNotBlank() && appPreferences.currentUserId.isNotBlank()) {
serverRepository.restoreSession(
preferences.currentServerId,
preferences.currentUserId,
appPreferences.currentServerId,
appPreferences.currentUserId,
)
}
isRestoringSession = false
}
val deviceProfile =
remember(preferences) {
createDeviceProfile(this@MainActivity, preferences, false)
}
val server = serverRepository.currentServer
val user = serverRepository.currentUser
val userDto = serverRepository.currentUserDto
val preferences =
UserPreferences(
appPreferences,
userDto?.configuration ?: DefaultUserConfiguration,
)
val deviceProfile =
remember(appPreferences) {
createDeviceProfile(this@MainActivity, preferences, false)
}
val backStack = rememberNavBackStack(Destination.Main)
val navigationManager = NavigationManager(backStack)

View file

@ -4,13 +4,14 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.datastore.core.DataStore
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.preferences.AppPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@ -21,12 +22,14 @@ class ServerRepository
constructor(
val serverDao: JellyfinServerDao,
val apiClient: ApiClient,
val userPreferencesDataStore: DataStore<UserPreferences>,
val userPreferencesDataStore: DataStore<AppPreferences>,
) {
private var _currentServer by mutableStateOf<JellyfinServer?>(null)
val currentServer get() = _currentServer
private var _currentUser by mutableStateOf<JellyfinUser?>(null)
val currentUser get() = _currentUser
private var _currentUserDto by mutableStateOf<UserDto?>(null)
val currentUserDto get() = _currentUserDto
suspend fun addAndChangeServer(server: JellyfinServer) {
withContext(Dispatchers.IO) {
@ -76,6 +79,7 @@ class ServerRepository
serverDao.addServer(updatedServer)
serverDao.addUser(updatedUser)
}
_currentUserDto = userDto
_currentServer = updatedServer
_currentUser = updatedUser
} catch (e: InvalidStatusException) {

View file

@ -8,8 +8,8 @@ import androidx.datastore.dataStoreFile
import androidx.room.Room
import com.github.damontecres.dolphin.data.AppDatabase
import com.github.damontecres.dolphin.data.JellyfinServerDao
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.preferences.UserPreferencesSerializer
import com.github.damontecres.dolphin.preferences.AppPreferences
import com.github.damontecres.dolphin.preferences.AppPreferencesSerializer
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -41,14 +41,14 @@ object DatabaseModule {
@Singleton
fun userPreferencesDataStore(
@ApplicationContext context: Context,
userPreferencesSerializer: UserPreferencesSerializer,
): DataStore<UserPreferences> =
userPreferencesSerializer: AppPreferencesSerializer,
): DataStore<AppPreferences> =
DataStoreFactory.create(
serializer = userPreferencesSerializer,
produceFile = { context.dataStoreFile("user_preferences.pb") },
produceFile = { context.dataStoreFile("app_preferences.pb") },
corruptionHandler =
ReplaceFileCorruptionHandler(
produceNewData = { UserPreferences.getDefaultInstance() },
produceNewData = { AppPreferences.getDefaultInstance() },
),
)
}

View file

@ -0,0 +1,51 @@
package com.github.damontecres.dolphin.preferences
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.github.damontecres.dolphin.ui.preferences.AppPreference
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
class AppPreferencesSerializer
@Inject
constructor() : Serializer<AppPreferences> {
override val defaultValue: AppPreferences =
AppPreferences
.newBuilder()
.apply {
playbackPreferences =
PlaybackPreferences
.newBuilder()
.apply {
skipForwardMs =
AppPreference.SkipForward.defaultValue.seconds.inWholeMilliseconds
skipBackMs =
AppPreference.SkipBack.defaultValue.seconds.inWholeMilliseconds
controllerTimeoutMs = AppPreference.ControllerTimeout.defaultValue
seekBarSteps = AppPreference.SeekBarSteps.defaultValue.toInt()
}.build()
}.build()
override suspend fun readFrom(input: InputStream): AppPreferences {
try {
return AppPreferences.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(
t: AppPreferences,
output: OutputStream,
) = t.writeTo(output)
}
inline fun AppPreferences.update(block: AppPreferences.Builder.() -> Unit): AppPreferences = toBuilder().apply(block).build()
inline fun AppPreferences.updatePlaybackPreferences(block: PlaybackPreferences.Builder.() -> Unit): AppPreferences =
update {
playbackPreferences = playbackPreferences.toBuilder().apply(block).build()
}

View file

@ -0,0 +1,26 @@
package com.github.damontecres.dolphin.preferences
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.UserConfiguration
data class UserPreferences(
val appPreferences: AppPreferences,
val userConfig: UserConfiguration,
)
val DefaultUserConfiguration =
UserConfiguration(
playDefaultAudioTrack = true,
displayMissingEpisodes = false,
groupedFolders = listOf(),
subtitleMode = SubtitlePlaybackMode.DEFAULT,
displayCollectionsView = false,
enableLocalPassword = false,
orderedViews = listOf(),
latestItemsExcludes = listOf(),
myMediaExcludes = listOf(),
hidePlayedInLatest = true,
rememberAudioSelections = true,
rememberSubtitleSelections = true,
enableNextEpisodeAutoPlay = true,
)

View file

@ -1,27 +0,0 @@
package com.github.damontecres.dolphin.preferences
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Inject
class UserPreferencesSerializer
@Inject
constructor() : Serializer<UserPreferences> {
override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()
override suspend fun readFrom(input: InputStream): UserPreferences {
try {
return UserPreferences.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(
t: UserPreferences,
output: OutputStream,
) = t.writeTo(output)
}

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,
)
}

View file

@ -3,8 +3,17 @@ syntax = "proto3";
option java_package = "com.github.damontecres.dolphin.preferences";
option java_multiple_files = true;
message UserPreferences {
message PlaybackPreferences {
int64 skip_forward_ms = 1;
int64 skip_back_ms = 2;
int64 controller_timeout_ms = 3;
int32 seek_bar_steps = 4;
}
message AppPreferences {
// The currently signed in server and user IDs, mostly for restoring a session
string current_server_id = 1;
string current_user_id = 2;
}
PlaybackPreferences playback_preferences = 3;
}

View file

@ -18,4 +18,22 @@
<string name="more">More</string>
<string name="mark_unwatched">Mark as unwatched</string>
<string name="mark_watched">Mark as watched</string>
<string name="cancel">Cancel</string>
<string name="save">Save</string>
<string name="app_name_long">Dolphin</string>
<string name="basic_interface">Basic Interface</string>
<string name="playback">Playback</string>
<string name="about">About</string>
<string name="advanced_settings">Advanced Settings</string>
<string name="advanced_ui">Advanced UI</string>
<string name="install_update">Install update</string>
<string name="installed_version">Installed version</string>
<string name="show">Show</string>
<string name="hide">Hide</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="skip_forward_preference">Skip forward</string>
<string name="skip_back_preference">Skip back</string>
<string name="hide_controller_timeout">Hide playback controls</string>
<string name="seek_bar_steps">Seek bar steps</string>
</resources>