mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Preferences updates
This commit is contained in:
parent
54bdb25d49
commit
60de501851
25 changed files with 1544 additions and 56 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue