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.MaterialTheme
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import com.github.damontecres.dolphin.data.ServerRepository
|
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.preferences.UserPreferences
|
||||||
import com.github.damontecres.dolphin.ui.CoilConfig
|
import com.github.damontecres.dolphin.ui.CoilConfig
|
||||||
import com.github.damontecres.dolphin.ui.nav.ApplicationContent
|
import com.github.damontecres.dolphin.ui.nav.ApplicationContent
|
||||||
|
|
@ -43,7 +45,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
lateinit var serverRepository: ServerRepository
|
lateinit var serverRepository: ServerRepository
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var userPreferencesDataStore: DataStore<UserPreferences>
|
lateinit var userPreferencesDataStore: DataStore<AppPreferences>
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var okHttpClient: OkHttpClient
|
lateinit var okHttpClient: OkHttpClient
|
||||||
|
|
@ -61,23 +63,31 @@ class MainActivity : AppCompatActivity() {
|
||||||
CoilConfig(serverRepository, okHttpClient, false)
|
CoilConfig(serverRepository, okHttpClient, false)
|
||||||
|
|
||||||
var isRestoringSession by remember { mutableStateOf(true) }
|
var isRestoringSession by remember { mutableStateOf(true) }
|
||||||
val preferences by userPreferencesDataStore.data.collectAsState(null)
|
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
||||||
preferences?.let { preferences ->
|
appPreferences?.let { appPreferences ->
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (preferences.currentServerId.isNotBlank() && preferences.currentUserId.isNotBlank()) {
|
if (appPreferences.currentServerId.isNotBlank() && appPreferences.currentUserId.isNotBlank()) {
|
||||||
serverRepository.restoreSession(
|
serverRepository.restoreSession(
|
||||||
preferences.currentServerId,
|
appPreferences.currentServerId,
|
||||||
preferences.currentUserId,
|
appPreferences.currentUserId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
isRestoringSession = false
|
isRestoringSession = false
|
||||||
}
|
}
|
||||||
val deviceProfile =
|
|
||||||
remember(preferences) {
|
|
||||||
createDeviceProfile(this@MainActivity, preferences, false)
|
|
||||||
}
|
|
||||||
val server = serverRepository.currentServer
|
val server = serverRepository.currentServer
|
||||||
val user = serverRepository.currentUser
|
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 backStack = rememberNavBackStack(Destination.Main)
|
||||||
val navigationManager = NavigationManager(backStack)
|
val navigationManager = NavigationManager(backStack)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.datastore.core.DataStore
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
import org.jellyfin.sdk.model.api.AuthenticationResult
|
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||||
|
import org.jellyfin.sdk.model.api.UserDto
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
@ -21,12 +22,14 @@ class ServerRepository
|
||||||
constructor(
|
constructor(
|
||||||
val serverDao: JellyfinServerDao,
|
val serverDao: JellyfinServerDao,
|
||||||
val apiClient: ApiClient,
|
val apiClient: ApiClient,
|
||||||
val userPreferencesDataStore: DataStore<UserPreferences>,
|
val userPreferencesDataStore: DataStore<AppPreferences>,
|
||||||
) {
|
) {
|
||||||
private var _currentServer by mutableStateOf<JellyfinServer?>(null)
|
private var _currentServer by mutableStateOf<JellyfinServer?>(null)
|
||||||
val currentServer get() = _currentServer
|
val currentServer get() = _currentServer
|
||||||
private var _currentUser by mutableStateOf<JellyfinUser?>(null)
|
private var _currentUser by mutableStateOf<JellyfinUser?>(null)
|
||||||
val currentUser get() = _currentUser
|
val currentUser get() = _currentUser
|
||||||
|
private var _currentUserDto by mutableStateOf<UserDto?>(null)
|
||||||
|
val currentUserDto get() = _currentUserDto
|
||||||
|
|
||||||
suspend fun addAndChangeServer(server: JellyfinServer) {
|
suspend fun addAndChangeServer(server: JellyfinServer) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|
@ -76,6 +79,7 @@ class ServerRepository
|
||||||
serverDao.addServer(updatedServer)
|
serverDao.addServer(updatedServer)
|
||||||
serverDao.addUser(updatedUser)
|
serverDao.addUser(updatedUser)
|
||||||
}
|
}
|
||||||
|
_currentUserDto = userDto
|
||||||
_currentServer = updatedServer
|
_currentServer = updatedServer
|
||||||
_currentUser = updatedUser
|
_currentUser = updatedUser
|
||||||
} catch (e: InvalidStatusException) {
|
} catch (e: InvalidStatusException) {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import androidx.datastore.dataStoreFile
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import com.github.damontecres.dolphin.data.AppDatabase
|
import com.github.damontecres.dolphin.data.AppDatabase
|
||||||
import com.github.damontecres.dolphin.data.JellyfinServerDao
|
import com.github.damontecres.dolphin.data.JellyfinServerDao
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
import com.github.damontecres.dolphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferencesSerializer
|
import com.github.damontecres.dolphin.preferences.AppPreferencesSerializer
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|
@ -41,14 +41,14 @@ object DatabaseModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun userPreferencesDataStore(
|
fun userPreferencesDataStore(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
userPreferencesSerializer: UserPreferencesSerializer,
|
userPreferencesSerializer: AppPreferencesSerializer,
|
||||||
): DataStore<UserPreferences> =
|
): DataStore<AppPreferences> =
|
||||||
DataStoreFactory.create(
|
DataStoreFactory.create(
|
||||||
serializer = userPreferencesSerializer,
|
serializer = userPreferencesSerializer,
|
||||||
produceFile = { context.dataStoreFile("user_preferences.pb") },
|
produceFile = { context.dataStoreFile("app_preferences.pb") },
|
||||||
corruptionHandler =
|
corruptionHandler =
|
||||||
ReplaceFileCorruptionHandler(
|
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.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class DialogParams(
|
||||||
|
val fromLongClick: Boolean,
|
||||||
|
val title: String,
|
||||||
|
val items: List<DialogItem>,
|
||||||
|
)
|
||||||
|
|
||||||
sealed interface DialogItemEntry
|
sealed interface DialogItemEntry
|
||||||
|
|
||||||
data object DialogItemDivider : 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) {
|
if (DEBUG) {
|
||||||
Timber.d(
|
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.detail.series.SeriesOverview
|
||||||
import com.github.damontecres.dolphin.ui.main.MainPage
|
import com.github.damontecres.dolphin.ui.main.MainPage
|
||||||
import com.github.damontecres.dolphin.ui.playback.PlaybackContent
|
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.SwitchServerContent
|
||||||
import com.github.damontecres.dolphin.ui.setup.SwitchUserContent
|
import com.github.damontecres.dolphin.ui.setup.SwitchUserContent
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
|
@ -46,6 +48,14 @@ fun DestinationContent(
|
||||||
Destination.ServerList -> SwitchServerContent(navigationManager, modifier)
|
Destination.ServerList -> SwitchServerContent(navigationManager, modifier)
|
||||||
Destination.UserList -> SwitchUserContent(navigationManager, modifier)
|
Destination.UserList -> SwitchUserContent(navigationManager, modifier)
|
||||||
|
|
||||||
|
Destination.Settings ->
|
||||||
|
PreferencesPage(
|
||||||
|
navigationManager,
|
||||||
|
preferences.appPreferences,
|
||||||
|
PreferenceScreenOption.BASIC,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
|
|
||||||
is Destination.MediaItem ->
|
is Destination.MediaItem ->
|
||||||
when (destination.type) {
|
when (destination.type) {
|
||||||
BaseItemKind.SERIES ->
|
BaseItemKind.SERIES ->
|
||||||
|
|
@ -104,7 +114,6 @@ fun DestinationContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
Destination.Search -> TODO()
|
Destination.Search -> TODO()
|
||||||
Destination.Settings -> TODO()
|
|
||||||
Destination.Setup -> 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.Destination
|
||||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
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 org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
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.R
|
||||||
import com.github.damontecres.dolphin.ui.AppColors
|
import com.github.damontecres.dolphin.ui.AppColors
|
||||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.stashapp.ui.components.playback.SeekBarImpl
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
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
|
* 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.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import com.github.damontecres.dolphin.ui.handleDPadKeyEvents
|
import com.github.damontecres.dolphin.ui.handleDPadKeyEvents
|
||||||
import com.github.damontecres.dolphin.ui.playback.ControllerViewState
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SeekBarImpl(
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.Animatable
|
||||||
import androidx.compose.animation.core.tween
|
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_package = "com.github.damontecres.dolphin.preferences";
|
||||||
option java_multiple_files = true;
|
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
|
// The currently signed in server and user IDs, mostly for restoring a session
|
||||||
string current_server_id = 1;
|
string current_server_id = 1;
|
||||||
string current_user_id = 2;
|
string current_user_id = 2;
|
||||||
}
|
|
||||||
|
PlaybackPreferences playback_preferences = 3;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,22 @@
|
||||||
<string name="more">More</string>
|
<string name="more">More</string>
|
||||||
<string name="mark_unwatched">Mark as unwatched</string>
|
<string name="mark_unwatched">Mark as unwatched</string>
|
||||||
<string name="mark_watched">Mark as watched</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>
|
</resources>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue